# ApiPay.kz API Documentation

## Complete Integration Guide for Kaspi Pay (Phone Payments)

## Base Configuration

- **Base URL:** `https://api.apipay.kz/api/v1`
- **Authentication:** Header `X-API-Key: your_api_key`
- **Content-Type:** `application/json`
- **Rate Limits:** 200 req/min на API-ключ (общий лимит Public API; при превышении 429 + Retry-After)

---

## Pricing

| Plan | Transaction Limit (per day) | Price/month |
|------|----------------------------|-------------|
| Старт (Start) | до 30/день | 10,000 KZT |
| Бизнес (Business) | до 100/день | 25,000 KZT |
| Про (Pro) | до 300/день | 60,000 KZT |
| Про Макс (Pro Max) | до 600/день | 90,000 KZT |

- Комиссия 0% с платежей — никаких скрытых сборов
- Больше 600 платежей в день — договорная цена, свяжитесь с нами
- Все тарифы включают ВСЕ функции: счета, подписки, каталог, возвраты, вебхуки
- В дневной лимит входят только счета, созданные через API (с api_key_id); счета из кабинета и песочницы не считаются
- Разовое превышение лимита не блокирует работу. При систематическом превышении включается ограничение: счета сверх лимита в течение суток не создаются (429 tariff_limit_reached), на следующие сутки счётчик обнуляется
- Ограничение снимается переходом на тариф выше — сразу после оплаты
- Для неравномерных продаж доступен помесячный подсчёт по запросу: бюджет на 30 дней, равный дневному лимиту × 30

---

## Prerequisites

1. Get API key in [ApiPay.kz](https://apipay.kz) dashboard
2. [Connect your Kaspi cashier](https://apipay.kz/connect-cashier) — yourself in the dashboard (Settings → Kaspi Authorization, ~1 min) or via WhatsApp support (+7 700 307 65 12)
   - Use a separate phone number for the cashier: after the connection is made, do not sign in to the Kaspi Pay app with that number — the connection breaks and invoices stop being issued until the cashier is reconnected
3. Set the webhook URL for your API key in the dashboard (Settings → API keys) to receive payment notifications

---

## Endpoints Overview (80)

| # | Method | Path | Description |
|---|--------|------|-------------|
| | **Invoices** | | |
| 1 | POST | /invoices | Создать счёт |
| 2 | POST | /invoices/qr | Создать QR-счёт или одноразовую ссылку на оплату |
| 3 | GET | /invoices | Список счетов |
| 4 | GET | /invoices/{id} | Получить счёт |
| 5 | PATCH | /invoices/{id} | Изменить внутреннюю заметку счёта |
| 6 | POST | /invoices/{id}/cancel | Отменить счёт |
| 7 | POST | /invoices/status/check | Массовая проверка статусов |
| 8 | GET | /invoices/{id}/receipt | Чек Kaspi по оплаченному счёту |
| | **Other** | | |
| 9 | POST | /clients/check | Проверить номер в Kaspi |
| | **Refunds** | | |
| 10 | POST | /invoices/{id}/refund | Создать возврат |
| 11 | GET | /invoices/{id}/refunds | Возвраты по счёту |
| 12 | GET | /refunds | Список возвратов |
| | **Catalog** | | |
| 13 | GET | /catalog/units | Единицы измерения |
| 14 | GET | /catalog | Список товаров каталога |
| 15 | POST | /catalog/upload-image | Загрузить изображение товара |
| 16 | POST | /catalog | Создать товары каталога |
| 17 | PATCH | /catalog/{id} | Обновить товар каталога |
| 18 | DELETE | /catalog/{id} | Удалить товар каталога |
| 19 | POST | /catalog/scan | Поиск товара в Нацкаталоге по штрихкоду |
| | **Subscriptions** | | |
| 20 | POST | /subscriptions | Создать подписку |
| 21 | GET | /subscriptions | Список подписок |
| 22 | GET | /subscriptions/{id} | Получить подписку |
| 23 | PUT | /subscriptions/{id} | Обновить подписку |
| 24 | POST | /subscriptions/{id}/pause | Приостановить подписку |
| 25 | POST | /subscriptions/{id}/resume | Возобновить подписку |
| 26 | POST | /subscriptions/{id}/cancel | Отменить подписку |
| 27 | GET | /subscriptions/{id}/invoices | Счета подписки |
| 80 | POST | /subscriptions/{id}/skip-period/preview | Что будет пропущено |
| 81 | POST | /subscriptions/{id}/skip-period | Пропустить период |
| | **Invoices** | | |
| 28 | POST | /invoices/{invoice}/simulate-status | Симулировать статус счёта |
| | **Other** | | |
| 29 | GET | /webhook-logs | Логи доставки вебхуков |
| 30 | GET | /webhook-logs/{id} | Одна доставка вебхука |
| | **Health** | | |
| 31 | GET | /status | Health-check |
| | **Invoices** | | |
| 32 | POST | /invoices/bulk | Пакетное создание счетов |
| 33 | GET | /invoices/stats | Статистика счетов |
| | **Other** | | |
| 34 | GET | /tariff | Статус своей подписки на ApiPay |
| 35 | GET | /tariff/plans | Каталог тарифов |
| 36 | GET | /account/health | Health своего аккаунта |
| 37 | GET | /connections | Список кассиров |
| 38 | POST | /connections | Создать кассира |
| 39 | PUT | /connections/{connection} | Переименовать кассира |
| 40 | DELETE | /connections/{connection} | Деактивировать кассира |
| 41 | POST | /connections/{connection}/primary | Назначить кассира основным |
| 42 | POST | /connections/{connection}/auth/init | Старт авторизации кассира |
| 43 | POST | /connections/{connection}/auth/send-phone | Отправить телефон кассира (SMS) |
| 44 | POST | /connections/{connection}/auth/verify-otp | Подтвердить OTP кассира |
| 45 | GET | /connections/{connection}/auth/status | Статус сессии кассира |
| 46 | POST | /connections/{connection}/auth/logout | Отключить кассира (disconnect) |
| | **Subscriptions** | | |
| 47 | POST | /subscriptions/{subscription}/simulate-invoice | Создать sandbox-счёт подписки |
| 48 | POST | /subscriptions/{subscription}/start-simulation | Запустить авто-симуляцию подписки |
| 49 | POST | /subscriptions/{subscription}/stop-simulation | Остановить авто-симуляцию подписки |
| | **Catalog** | | |
| 50 | GET | /catalog/webhook-logs | Логи доставок catalog.item_processed |
| 51 | GET | /catalog/queue | Остаток очереди приёма каталога + ETA |
| 52 | GET | /catalog/errors | Ошибки приёма каталога |
| 79 | POST | /catalog/bulk-delete | Массовое удаление позиций каталога |
| | **Other** | | |
| 54 | POST | /receipts/preview | Превью фискального чека |
| 55 | POST | /receipts | Выбить фискальный чек |
| 56 | GET | /receipts/{id} | Статус фискального чека |
| 57 | GET | /receipts | История фискальных чеков |
| 58 | POST | /static-qr | Создать печатный QR и ссылку на оплату под сделку (отложенный счёт) |
| 59 | GET | /static-qr | Список печатных QR организации |
| 60 | GET | /static-qr/{id} | Получить печатный QR под сделку |
| 61 | DELETE | /static-qr/{id} | Отключить печатный QR под сделку |
| 62 | POST | /qr-refunds | Старт QR-возврата (немедленный, deprecated) |
| 63 | GET | /qr-refunds/{id} | Статус сессии QR-возврата |
| 64 | GET | /qr-refunds/{id}/operations | Возвратные операции клиента |
| 65 | GET | /qr-refunds/{id}/operations/{ref} | Детали возвратной операции |
| 66 | POST | /qr-refunds/{id}/execute | Выполнить возврат (синхронно) |
| 67 | POST | /qr-refunds/{id}/simulate | Симулировать переход QR-возврата (sandbox) |
| 68 | POST | /qr-refunds/links | Выпустить ссылку «Возврат ApiPay» |
| 69 | DELETE | /qr-refunds/links/{id} | Отозвать неактивированную ссылку |
| 70 | GET | /cashbox/summary | Сводка по наличным за день |
| 71 | GET | /cashbox/shifts | Список кассовых смен |
| 72 | GET | /cashbox/reconciliation | Сверка наших счетов с кассой Kaspi |
| 73 | POST | /cashbox/shifts/close | Закрыть смену (async) |
| 74 | GET | /cashbox/operations/{id} | Статус кассовой операции (поллинг) |
| 75 | GET | /cashbox/shifts/{shift}/report | Ссылка на PDF-отчёт по смене |
| 76 | GET | /cashbox/settings | Текущие тумблеры кассы |
| 77 | PUT | /cashbox/settings/auto-close | Тумблер автозакрытия смены |
| 78 | PUT | /cashbox/settings/auto-withdrawal | Тумблер автоизъятия наличных |

---

## Health Check

### GET /status

Проверка доступности API. **Без авторизации** (`X-API-Key` не требуется).
Подпадает под общий гостевой лимит `60 запросов/мин на IP`.


**Response 200:** Сервис доступен

Content-Type: `application/json`

```json
{
  "status": "ok",
  "timestamp": "2026-07-05T14:30:00+00:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| status | string |  |
| timestamp | string | Текущее время сервера, ISO 8601 UTC (+00:00). |

---

## Invoices

### GET /invoices

Пагинированный список счетов организации с фильтрацией и сортировкой.
Пагинация — **плоская** `{current_page, data, total}` (не meta-обёртка).
Даты счетов в ОТВЕТЕ — UTC `+00:00` (как у всех ресурсов, см. «Форматы дат»).

Итоги по смене (продажи/возвраты/выручка и сверка с цифрами Kaspi) отдаёт
отдельный `GET /api/v1/cashbox/reconciliation`.

⚠️ **Список — не полная выручка организации.** Счета, выставленные через ApiPay,
попадают в него сразу. Продажи, проведённые в приложении Kaspi Pay мимо ApiPay —
например другим кассиром, — подтягиваются из истории Kaspi: не мгновенно и не всегда
полностью. Подтянутая продажа датируется временем самой операции и появляется внутри
уже пройденного периода: для отчётности перезапрашивайте окно, а не только новые
записи. Отделить одно от другого можно фильтром `origin`.

**Окно `date_from`/`date_to` задаётся в зоне мерчанта (Asia/Almaty)**, а не в UTC:

- голая дата `2026-08-01` — это КАЛЕНДАРНЫЕ СУТКИ мерчанта целиком
  (`date_to` растягивается до `23:59:59`);
- дата со временем — точный момент; минуты и секунды опциональны, поэтому
  `2026-08-01 11`, `2026-08-01 11:00` и `2026-08-01 11:00:00` означают одно и то же.
  Так задаётся смена, переходящая через полночь
  (`date_from=2026-08-03 11&date_to=2026-08-04 03`);
- явный офсет (`2026-08-01T11:00:00+03:00`, `...Z`) уважается как указано.
  В query-строке `+` нужно кодировать как `%2B`.

Обе границы включительные. ⚠️ Растягивается до конца периода ТОЛЬКО голая дата:
`date_to=2026-08-01 18` — это `18:00:00`, а не конец восемнадцатого часа.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| search | string | — | Поиск ПОДСТРОКОЙ (без точного совпадения; `%` и `_` ищутся буквально) по `id`,
`description`, `internal_comment`, `client_name`, `external_order_id`. Если в
строке есть цифры — ещё по телефону (формы `7…`/`8…`/10 цифр нормализуются) и по
`kaspi_invoice_id`.
 |
| status[] | array | — | Фильтр по статусам (можно несколько значений).
`partially_refunded` — счёт, по которому уже был частичный возврат: деньги он
принял, поэтому фильтр «покажи оплаченные» без него неполон.
⚠️ Для сверки кассы фильтруйте по обоим статусам сразу
(`status[]=paid&status[]=partially_refunded`) либо берите итоги из
`GET /api/v1/cashbox/reconciliation`.
 |
| origin | string | all | Происхождение счёта.
`all` — всё подряд (по умолчанию).
`apipay` — счёт выставлен через ApiPay: кабинетом, API-ключом, подпиской или печатным QR.
`kaspi` — продажа проведена в приложении Kaspi Pay мимо ApiPay и подтянута из истории Kaspi.
Половины не пересекаются: `apipay` + `kaspi` в одном окне дают ровно `all`.
 |
| date_from | string | — | Начало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty). |
| date_to | string | — | Конец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from. |
| date_field | string | created_at | По какому времени резать окно.
`created_at` — момент выставления счёта (по умолчанию).
`paid_at` — момент оплаты; так операции раскладывает по сменам терминал, поэтому
для сверки кассы используйте его. Неоплаченные счета при этом отсеиваются.
 |
| sort_by | string | created_at | Поле сортировки (невалидное значение → created_at). |
| sort_order | string | desc |  |
| per_page | integer | 10 |  |
| page | integer | 1 |  |

**Response 200:** Список счетов

Content-Type: `application/json`

```json
{
  "current_page": 1,
  "data": [
    {
      "id": 1,
      "amount": "5000.00",
      "description": null,
      "external_order_id": "order-123",
      "status": "processing",
      "kaspi_invoice_id": "13234689513",
      "kaspi_qr_link": "https://kaspi.kz/qr/pay?tranId=QR13234689513",
      "phone": "87001234567",
      "client_name": "Иван Иванов",
      "internal_comment": "Айгуль, самовывоз",
      "buyer_bin": null,
      "is_sandbox": false,
      "is_recurring": false,
      "is_imported": false,
      "subtotal": null,
      "discount_sum": null,
      "discount_percentage": null,
      "total_refunded": "0.00",
      "is_fully_refunded": false,
      "error_message": null,
      "error_code": "idempotency_conflict",
      "paid_at": null,
      "created_at": "2026-01-10T12:00:00+00:00",
      "items": [
        {
          "id": 1,
          "invoice_id": 42,
          "catalog_item_id": null,
          "name": "Coffee Latte",
          "price": "1800.00",
          "count": 2,
          "unit_id": 1,
          "discount": null,
          "barcode": null,
          "ntin": null,
          "gtin": null
        }
      ]
    }
  ],
  "total": 48
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.amount | string |  |
| data.description | string \| null |  |
| data.external_order_id | string \| null |  |
| data.status | string | Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true. |
| data.kaspi_invoice_id | string \| null |  |
| data.kaspi_qr_link | string \| null | QR-ссылка Kaspi на этот счёт — нарисуйте из неё QR-код или откройте на телефоне покупателя. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов. |
| data.phone | string |  |
| data.client_name | string \| null |  |
| data.internal_comment | string \| null | Внутренняя заметка мерчанта; в Kaspi не уходит, плательщику не видна. |
| data.buyer_bin | string \| null | ИИН/БИН покупателя, если был передан при создании. |
| data.is_sandbox | boolean |  |
| data.is_recurring | boolean | Счёт создан подпиской. |
| data.is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| data.subtotal | string \| null |  |
| data.discount_sum | string \| null |  |
| data.discount_percentage | string \| null |  |
| data.total_refunded | string |  |
| data.is_fully_refunded | boolean |  |
| data.error_message | string \| null |  |
| data.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| data.paid_at | string \| null |  |
| data.created_at | string |  |
| data.items | object[] |  |
| data.items.id | integer |  |
| data.items.invoice_id | integer |  |
| data.items.catalog_item_id | integer \| null | null если товар удалён. |
| data.items.name | string |  |
| data.items.price | string |  |
| data.items.count | integer |  |
| data.items.unit_id | integer \| null |  |
| data.items.discount | string \| null |  |
| data.items.barcode | string \| null | Нацкаталог-поля позиции (если были в корзине). |
| data.items.ntin | string \| null |  |
| data.items.gtin | string \| null |  |
| total | integer | Всего счетов под фильтром (для пагинации). |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### POST /invoices

Создаёт счёт на оплату по номеру телефона. Обработка **асинхронная**: ответ `201`
приходит со `status: "processing"` (Kaspi ещё не вызван), финальный статус
(`pending`/`error`) — вебхуком `invoice.status_changed` или поллингом
`GET /invoices/{id}`. Не пересоздавайте счёт, пока он в `processing`.

Два режима суммы: без корзины (`amount`) или с корзиной (`cart_items`, сумма
считается сервером). Организация с каталогом может выставить счёт и одной суммой,
без `cart_items`. Обратное неверно: организация без каталога с `cart_items`
получит `422` с `error_code: catalog_not_supported`.
Так же ведёт себя `POST /invoices/bulk` — per-item, тем же кодом.
Для `POST /invoices/qr` и `POST /static-qr` правило другое — там у организации
с каталогом корзина обязательна.

⛔ **Сумма — только целые тенге.** Счёт на номер телефона принимает
исключительно целое число тенге; дробная сумма (`175.74`) отбивается сразу с
`422 amount_must_be_whole_tenge`, и это относится и к итогу корзины. Проверяется
**итог после скидок**: `discount_percentage` считается построчно, поэтому даже при
целых ценах итог может стать дробным (`999 ₸` со скидкой `10 %` → `899.10`) —
округляйте цены позиций или процент скидки. Минимальная сумма — `1`. Нужны тиыны —
выставляйте через `POST /invoices/qr`: там дробные суммы принимаются и оплачиваются.

⚠️ **Технические работы бывают двух видов, и отвечают они по-разному.**
(1) Приём новых счетов остановлен целиком — `503 invoices_disabled` без `Retry-After`
на `POST /invoices`, `/invoices/bulk`, `/invoices/qr` и `POST /static-qr`; счёт НЕ
создан, повторите позже. (2) Приостановлены только запросы в Kaspi — телефонный счёт
(здесь и в `/invoices/bulk`) принимается как обычно: `201`, `status: "processing"`,
и уходит в Kaspi сам после окончания работ; держите его как любой `processing` и не
пересоздавайте. `POST /invoices/qr` в этом режиме отвечает `503 invoices_disabled` с
заголовком `Retry-After`: QR без ответа Kaspi не выпустить.

💡 Позиция каталога без цены: здесь её можно выставить, передав `cart_items[].price`
(см. `CartItem.price`). На остальных поверхностях такая позиция отклоняется.

⚠️ Скидка здесь считается иначе, чем на QR-счёте (`POST /invoices/qr`): там процент
только целый и скидка строки округляется ВВЕРХ до целого тенге. Одинаковая корзина
со скидкой может дать разный итог телефонного и QR-счёта.


**Request:**
```json
{
  "phone_number": "87001234567",
  "amount": 5000,
  "description": "",
  "internal_comment": null,
  "buyer_bin": "990101300123",
  "external_order_id": "",
  "external_order_id_idempotency": "",
  "kaspi_connection_id": 0,
  "cart_items": [
    {
      "catalog_item_id": 0,
      "count": 1,
      "price": null
    }
  ],
  "discount_percentage": 1
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| phone_number | string | Yes |  |
| amount | number | No | Сумма в тенге, только целая. Обязательна, если нет cart_items. Игнорируется при наличии cart_items — там проверяется итог корзины после скидок. Дробная сумма → `422 amount_must_be_whole_tenge`. |
| description | string | No | Описание счёта. Уходит в Kaspi как комментарий к платежу.

⚠️ Kaspi показывает покупателю только **первые 60 символов** — всё, что длиннее,
он отбрасывает молча, не сообщая об этом ни вам, ни нам. Вынесите в начало то, по
чему плательщик узнает платёж (номер заказа, имя), а подробности уберите.

⛔ Лимит описания — **60 символов**: описание длиннее отклоняется с
`error_code: description_too_long` (HTTP 422).

Поле необязательное. Если его не передать, описание подставим мы: при `cart_items` —
названия позиций через запятую (целыми позициями, сколько поместится в видимые 60
символов), без корзины — «Оплата счёта». Раньше в этом случае покупателю уходило
английское «Payment».
 |
| internal_comment | string | No | Внутренняя заметка мерчанта («кто это / что это»). **В Kaspi не передаётся,
плательщик её не видит** и в чек она не попадает. Возвращается в `GET /invoices`,
`GET /invoices/{id}`, в выгрузке кабинета и в вебхуках `invoice.status_changed`
и `invoice.qr_scanned` (только если non-null). Ищется подстрокой через `search`.
Редактируется после создания — `PATCH /invoices/{id}`, в любом статусе.
 |
| buyer_bin | string | No | ИИН/БИН покупателя — строка ровно из 12 цифр (числом не передавать: теряется ведущий ноль). Необязательный. Передаётся в Kaspi для фискального чека; Kaspi номер не проверяет, форма проверяется у нас (422 errors.buyer_bin). В вебхуках не отдаётся. |
| external_order_id | string | No |  |
| external_order_id_idempotency | string | No | Ключ идемпотентности (уникален в пределах организации). Дубль → 409. Исключение —
перевыставление: если прежний счёт с этим ключом в статусе expired/cancelled/error,
создаётся новый. Пусто/null → выключено.
 |
| kaspi_connection_id | integer | No | Кассир (default — primary). Обязателен, если >1 активной connection без primary (иначе 422 connection_ambiguous). |
| cart_items | array | No | Корзина. Для этого эндпоинта необязательна и организации с каталогом — счёт можно выставить одной суммой в amount. Организация без каталога с cart_items получит 422. |
| discount_percentage | number | No | Глобальная скидка на весь чек (%). |

**Response 201:** Счёт создан (status=processing)

Content-Type: `application/json`

```json
{
  "id": 42,
  "amount": "5000.00",
  "status": "processing",
  "paid_at": null,
  "phone": "87001234567",
  "is_imported": false,
  "created_at": "2026-01-10T12:00:00+00:00",
  "kaspi_source_type": "",
  "kaspi_sale_type": "",
  "subtotal": "",
  "discount_sum": "",
  "discount_percentage": "",
  "error_message": ""
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| amount | string |  |
| status | string |  |
| paid_at | string \| null | null при создании. |
| phone | string |  |
| is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| created_at | string |  |
| kaspi_source_type | string | Только если non-null. |
| kaspi_sale_type | string | Только если non-null. |
| subtotal | string | Только при наличии скидки/корзины (вместе с discount_sum). |
| discount_sum | string |  |
| discount_percentage | string | Только если передан. |
| error_message | string | Только при status=error. |

**Errors:**
- `400` — Организация не найдена/не верифицирована, лимит sandbox или сессия Kaspi не настроена
- `401` — X-API-Key отсутствует или невалиден
- `403` — `kyc_rejected` — мерчант отклонён на KYC-модерации; `tariff_inactive` — подписка на
ApiPay не активна (продлите тариф в кабинете). Грейс-периода у тарифа нет —
блокировка наступает сразу после `expires_at`, который приходит в теле ответа.

- `409` — `duplicate_idempotency_key` — дубль ключа идемпотентности (external_order_id_idempotency
уже использован); `kaspi_session_expired` — сессия кассира Kaspi мертва, счёт не создан
(нужна переавторизация кассира, ретрай не поможет).

- `422` — Ошибка валидации. Особые случаи: `connection_ambiguous` (>1 активной connection
без primary, укажите `kaspi_connection_id`); ошибки скидки/каталога;
`content_rejected` (контент-скрин, только в режиме block);
`amount_must_be_whole_tenge` (сумма с тиынами по телефонному счёту не
принимается; округлите либо используйте `POST /invoices/qr`);
позиция корзины с `operation=delete` — `errors["cart_items.N.catalog_item_id"]`,
отдельного `error_code` у этой ветки нет (см. `CartItem.catalog_item_id`);
позиция каталога без цены и без `cart_items[].price` —
`errors["cart_items.N.catalog_item_id"]` с текстом
`Catalog item has no price set. Pass cart_items[].price explicitly.`;
`field_too_long` (страховочный код: значение поля превышает допустимую длину —
укоротите его; имя поля в `errors`, повтор с тем же телом бесполезен);
`description_too_long` (описание длиннее 60 символов — Kaspi показывает
покупателю только первые 60; укоротите описание).

- `429` — Превышен антифрод/триал/тарифный лимит на создание счетов. `error_code` — один из
`trial_daily_limit`, `tariff_limit_reached` (лимит оплаченного тарифа; `meta.mode`
различает суточный потолок и бюджет 30-дневного блока), `outstanding_recipient_limit`,
`outstanding_org_limit`, `recipient_fanout_exceeded`, `kyc_daily_limit_reached`
(мерчант без одобренной анкеты — боевые счета закрыты, `meta.limit=0`; песочница
работает и анкеты не требует).
`recipient_fanout_exceeded` не содержит `Retry-After` и `retry_after_seconds`;
это защитное ограничение, причину разбирают через поддержку.
У остальных перечисленных кодов есть время ожидания, но KYC-ограничение
снимается одобрением анкеты, а не наступлением следующего окна.

- `503` — Создание счёта временно недоступно. `error` / `error_code`:
* `kaspi_session_invalid` — сессия кассира Kaspi истекла (переподключите кассира);
* `invoices_disabled` — приём новых счетов приостановлен (технические работы). Счёт
  НЕ создан — повторите позже. Чтение счетов, отмена, возврат и статусы продолжают
  работать. У `POST /invoices/qr` при приостановке запросов в Kaspi приходит ещё
  заголовок `Retry-After`; при полной остановке приёма счетов его нет.


---

### GET /invoices/{id}

Полный объект счёта с позициями. Доп. лимит **1000/мин**
вместо общего 200/мин — под поллинг 1С без вебхуков.
Читает из БД/кэша, не бьёт в Kaspi (терминальные статусы кэшируются 24ч).
Статуса `refunded` не существует — полный возврат оставляет `paid` +
`is_fully_refunded=true`.


**Response 200:** Данные счёта

Content-Type: `application/json`

```json
{
  "id": 42,
  "amount": "15000.00",
  "phone_number": "87001234567",
  "description": null,
  "external_order_id": null,
  "status": "processing",
  "client_name": "Иван Иванов",
  "internal_comment": null,
  "buyer_bin": null,
  "is_sandbox": false,
  "is_imported": false,
  "total_refunded": "0.00",
  "is_fully_refunded": false,
  "kaspi_invoice_id": "13234689513",
  "kaspi_qr_link": "https://kaspi.kz/qr/pay?tranId=QR13234689513",
  "error_message": null,
  "error_code": "idempotency_conflict",
  "items": [
    {
      "id": 1,
      "invoice_id": 42,
      "catalog_item_id": null,
      "name": "Coffee Latte",
      "price": "1800.00",
      "count": 2,
      "unit_id": 1,
      "discount": null,
      "barcode": null,
      "ntin": null,
      "gtin": null
    }
  ],
  "subtotal": "",
  "discount_sum": "",
  "discount_percentage": "",
  "paid_at": null,
  "created_at": "2026-01-10T12:00:00+00:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| amount | string |  |
| phone_number | string |  |
| description | string \| null |  |
| external_order_id | string \| null |  |
| status | string | Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true. |
| client_name | string \| null |  |
| internal_comment | string \| null | Внутренняя заметка мерчанта (правится через PATCH /invoices/{id}). |
| buyer_bin | string \| null | ИИН/БИН покупателя, если был передан при создании. |
| is_sandbox | boolean |  |
| is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| total_refunded | string |  |
| is_fully_refunded | boolean |  |
| kaspi_invoice_id | string \| null |  |
| kaspi_qr_link | string \| null | QR-ссылка Kaspi на этот счёт — нарисуйте из неё QR-код или откройте на телефоне покупателя. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов. |
| error_message | string \| null |  |
| error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| items | object[] |  |
| items.id | integer |  |
| items.invoice_id | integer |  |
| items.catalog_item_id | integer \| null | null если товар удалён. |
| items.name | string |  |
| items.price | string |  |
| items.count | integer |  |
| items.unit_id | integer \| null |  |
| items.discount | string \| null |  |
| items.barcode | string \| null | Нацкаталог-поля позиции (если были в корзине). |
| items.ntin | string \| null |  |
| items.gtin | string \| null |  |
| subtotal | string | Только при наличии скидки. |
| discount_sum | string | Только при наличии скидки. |
| discount_percentage | string | Только если передан. |
| paid_at | string \| null |  |
| created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Счёт не найден

---

### PATCH /invoices/{id}

Правит **только** `internal_comment` — внутреннюю заметку мерчанта («кто это /
что это»). Заметка в Kaspi не передаётся, плательщик её не видит, в чек она не
попадает; на сумму, статус и возвраты не влияет. Другие поля счёта этим методом
не редактируются.

Доступно в **любом** статусе, включая `paid` и `expired`: закрытый счёт всё ещё
можно разметить — в том числе счета, приехавшие синком из истории Kaspi.

`null` или пустая строка стирают заметку. Тело **без ключа** `internal_comment` →
`422` (молчаливого «ничего не изменил» нет). Вебхук эта операция не порождает —
новое значение уедет со следующим штатным событием по счёту.


**Request:**
```json
{
  "internal_comment": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| internal_comment | string | Yes | Новая заметка. `null` или пустая строка стирают её. Ключ обязателен: тело без него → 422. |

**Response 200:** Заметка сохранена; в теле — счёт целиком (та же форма, что GET /invoices/{id})

Content-Type: `application/json`

```json
{
  "id": 42,
  "amount": "15000.00",
  "phone_number": "87001234567",
  "description": null,
  "external_order_id": null,
  "status": "processing",
  "client_name": "Иван Иванов",
  "internal_comment": null,
  "buyer_bin": null,
  "is_sandbox": false,
  "is_imported": false,
  "total_refunded": "0.00",
  "is_fully_refunded": false,
  "kaspi_invoice_id": "13234689513",
  "kaspi_qr_link": "https://kaspi.kz/qr/pay?tranId=QR13234689513",
  "error_message": null,
  "error_code": "idempotency_conflict",
  "items": [
    {
      "id": 1,
      "invoice_id": 42,
      "catalog_item_id": null,
      "name": "Coffee Latte",
      "price": "1800.00",
      "count": 2,
      "unit_id": 1,
      "discount": null,
      "barcode": null,
      "ntin": null,
      "gtin": null
    }
  ],
  "subtotal": "",
  "discount_sum": "",
  "discount_percentage": "",
  "paid_at": null,
  "created_at": "2026-01-10T12:00:00+00:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| amount | string |  |
| phone_number | string |  |
| description | string \| null |  |
| external_order_id | string \| null |  |
| status | string | Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true. |
| client_name | string \| null |  |
| internal_comment | string \| null | Внутренняя заметка мерчанта (правится через PATCH /invoices/{id}). |
| buyer_bin | string \| null | ИИН/БИН покупателя, если был передан при создании. |
| is_sandbox | boolean |  |
| is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| total_refunded | string |  |
| is_fully_refunded | boolean |  |
| kaspi_invoice_id | string \| null |  |
| kaspi_qr_link | string \| null | QR-ссылка Kaspi на этот счёт — нарисуйте из неё QR-код или откройте на телефоне покупателя. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов. |
| error_message | string \| null |  |
| error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| items | object[] |  |
| items.id | integer |  |
| items.invoice_id | integer |  |
| items.catalog_item_id | integer \| null | null если товар удалён. |
| items.name | string |  |
| items.price | string |  |
| items.count | integer |  |
| items.unit_id | integer \| null |  |
| items.discount | string \| null |  |
| items.barcode | string \| null | Нацкаталог-поля позиции (если были в корзине). |
| items.ntin | string \| null |  |
| items.gtin | string \| null |  |
| subtotal | string | Только при наличии скидки. |
| discount_sum | string | Только при наличии скидки. |
| discount_percentage | string | Только если передан. |
| paid_at | string \| null |  |
| created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Счёт не найден
- `422` — Ошибка валидации

---

### POST /invoices/{id}/cancel

Отменяет счёт в статусе `pending` или `processing`. Sandbox / счёт без
`kaspi_invoice_id` → `200` (синхронно) + вебхук `cancelled`. Production → `202`,
статус `cancelling` — **не считайте счёт отменённым сразу**. Итог придёт вебхуком
(`cancelled` — отмена прошла, либо `error` с `invoice_already_paid`/
`invoice_already_cancelled`/`invoice_not_found_in_kaspi`). Если Kaspi отказал
(обычно счёт уже оплачен) — счёт тихо вернётся в `pending`, реальный статус
(обычно `paid`) доставит sync.

⛔ **Отмена QR-счёта (`is_qr_token: true`) не поддерживается — `409 qr_cancel_unsupported`.**
Запрос отклоняется сразу: статус счёта не меняется, в Kaspi ничего не уходит.
QR перестаёт быть оплачиваемым по истечении окна на скан и уезжает в `expired` —
точный момент указан в поле `expires_at` ответа, длительность окна константой не
зашивайте. Нужен другой счёт — просто выставьте новый: QR сосуществуют, старый
не мешает.


**Response 200:** Счёт отменён синхронно (sandbox / без kaspi_invoice_id)

Content-Type: `application/json`

```json
{
  "message": "Invoice cancelled",
  "invoice": {
    "id": 42,
    "amount": "15000.00",
    "phone_number": "87001234567",
    "description": null,
    "external_order_id": null,
    "status": "processing",
    "client_name": "Иван Иванов",
    "internal_comment": null,
    "buyer_bin": null,
    "is_sandbox": false,
    "is_imported": false,
    "total_refunded": "0.00",
    "is_fully_refunded": false,
    "kaspi_invoice_id": "13234689513",
    "kaspi_qr_link": "https://kaspi.kz/qr/pay?tranId=QR13234689513",
    "error_message": null,
    "error_code": "idempotency_conflict",
    "items": [
      {
        "id": 1,
        "invoice_id": 42,
        "catalog_item_id": null,
        "name": "Coffee Latte",
        "price": "1800.00",
        "count": 2,
        "unit_id": 1,
        "discount": null,
        "barcode": null,
        "ntin": null,
        "gtin": null
      }
    ],
    "subtotal": "",
    "discount_sum": "",
    "discount_percentage": "",
    "paid_at": null,
    "created_at": "2026-01-10T12:00:00+00:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| invoice | object | Полный объект счёта (GET /invoices/{id}). Даты UTC +00:00. |
| invoice.id | integer |  |
| invoice.amount | string |  |
| invoice.phone_number | string |  |
| invoice.description | string \| null |  |
| invoice.external_order_id | string \| null |  |
| invoice.status | string | Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true. |
| invoice.client_name | string \| null |  |
| invoice.internal_comment | string \| null | Внутренняя заметка мерчанта (правится через PATCH /invoices/{id}). |
| invoice.buyer_bin | string \| null | ИИН/БИН покупателя, если был передан при создании. |
| invoice.is_sandbox | boolean |  |
| invoice.is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| invoice.total_refunded | string |  |
| invoice.is_fully_refunded | boolean |  |
| invoice.kaspi_invoice_id | string \| null |  |
| invoice.kaspi_qr_link | string \| null | QR-ссылка Kaspi на этот счёт — нарисуйте из неё QR-код или откройте на телефоне покупателя. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов. |
| invoice.error_message | string \| null |  |
| invoice.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| invoice.items | object[] |  |
| invoice.items.id | integer |  |
| invoice.items.invoice_id | integer |  |
| invoice.items.catalog_item_id | integer \| null | null если товар удалён. |
| invoice.items.name | string |  |
| invoice.items.price | string |  |
| invoice.items.count | integer |  |
| invoice.items.unit_id | integer \| null |  |
| invoice.items.discount | string \| null |  |
| invoice.items.barcode | string \| null | Нацкаталог-поля позиции (если были в корзине). |
| invoice.items.ntin | string \| null |  |
| invoice.items.gtin | string \| null |  |
| invoice.subtotal | string | Только при наличии скидки. |
| invoice.discount_sum | string | Только при наличии скидки. |
| invoice.discount_percentage | string | Только если передан. |
| invoice.paid_at | string \| null |  |
| invoice.created_at | string |  |

**Response 202:** Отмена поставлена в очередь (production)

Content-Type: `application/json`

```json
{
  "message": "Invoice cancellation queued",
  "invoice_id": 42
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| invoice_id | integer |  |

**Errors:**
- `400` — Счёт нельзя отменить (не в статусе pending/processing)
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Счёт не найден
- `409` — `qr_cancel_unsupported` — счёт выставлен по QR, отмена для него не
поддерживается. Статус счёта не изменён. Дождитесь `expired` (момент — в
`expires_at`).


---

### POST /invoices/{id}/refund

Принятие в очередь не означает успех. При потере ответа сначала прочитайте состояние.
`processing` с `error_code: refund_execution_uncertain` требует ручной проверки, повтор запрещён.
Способ возврата, выбранный в настройках кабинета, на возвраты через API не влияет.
Полный или частичный возврат по оплаченному счёту. Без `amount` — полный возврат.
Ответ `201` = возврат принят и поставлен в очередь (`status: pending`). Итог —
вебхук `invoice.refunded` (`completed`/`failed`, причина в `refund.error_code`).
Для счетов с корзиной можно указать `return_items` — на позицию **ровно одно** из
полей `count` (целые штуки) ИЛИ `amount` (произвольная сумма); оба или ни одного → `422`.


**Request:**
```json
{
  "amount": 0.01,
  "reason": "",
  "return_items": [
    {
      "catalog_item_id": 0,
      "count": 1,
      "amount": 0.01
    }
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| amount | number | No | Сумма возврата. Без неё — полный возврат. |
| reason | string | No |  |
| return_items | array | No | Позиционный возврат — на позицию РОВНО одно из count/amount. |

**Response 201:** Возврат создан и поставлен в очередь

Content-Type: `application/json`

```json
{
  "message": "Refund queued for processing",
  "refund": {
    "id": 5,
    "invoice_id": 42,
    "amount": "5000.00",
    "reason": null,
    "status": "pending",
    "created_at": "2026-01-10T12:00:00+00:00"
  },
  "invoice": {
    "id": 42,
    "amount": "15000.00",
    "total_refunded": "5000.00",
    "available_for_refund": 10000,
    "pending_refund_amount": 0
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| refund | object |  |
| refund.id | integer |  |
| refund.invoice_id | integer |  |
| refund.amount | string |  |
| refund.reason | string \| null |  |
| refund.status | string |  |
| refund.created_at | string |  |
| invoice | object |  |
| invoice.id | integer |  |
| invoice.amount | string |  |
| invoice.total_refunded | string |  |
| invoice.available_for_refund | number | Сумма, доступная для возврата (число, не строка). |
| invoice.pending_refund_amount | number | Сумма ожидающих возвратов (число, не строка). |

**Errors:**
- `400` — Нельзя сделать возврат (счёт не оплачен / полностью возвращён / превышает доступное)
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Счёт не найден
- `409` — qr_refund_link_invoice_busy — обычный или QR-возврат по этому счёту ещё выполняется либо имеет неизвестный исход. Новый возврат не создан.
- `422` — refund_window_expired / контент-скрин / ошибки return_items

---

### GET /invoices/{id}/refunds

Список возвратов конкретного счёта с агрегатами по счёту. Даты — UTC +00:00.

**Response 200:** Возвраты счёта

Content-Type: `application/json`

```json
{
  "invoice": {
    "id": 42,
    "amount": "15000.00",
    "total_refunded": "5000.00",
    "available_for_refund": 10000,
    "is_fully_refunded": false
  },
  "refunds": [
    {
      "id": 5,
      "invoice_id": 42,
      "qr_refund_session_id": null,
      "amount": "5000.00",
      "status": "completed",
      "reason": null,
      "kaspi_refund_id": "1126827352",
      "error_message": null,
      "error_code": "idempotency_conflict",
      "items": [
        {
          "id": 0,
          "refund_id": 0,
          "invoice_item_id": 0,
          "catalog_item_id": null,
          "name": "",
          "price": "",
          "count": 0,
          "amount": ""
        }
      ],
      "created_at": "2026-01-01T12:00:00+05:00"
    }
  ],
  "total": 1
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| invoice | object |  |
| invoice.id | integer |  |
| invoice.amount | string |  |
| invoice.total_refunded | string |  |
| invoice.available_for_refund | number |  |
| invoice.is_fully_refunded | boolean |  |
| refunds | object[] |  |
| refunds.id | integer |  |
| refunds.invoice_id | integer |  |
| refunds.qr_refund_session_id | integer \| null | ID связанной QR-сессии после сверки идентификатора операции при синхронизации. Используйте для исключения повторного учёта. |
| refunds.amount | string |  |
| refunds.status | string |  |
| refunds.reason | string \| null |  |
| refunds.kaspi_refund_id | string \| null | null пока не проведён / при неудаче. |
| refunds.error_message | string \| null | Текст причины (для failed). В вебхуке этого поля нет. |
| refunds.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| refunds.items | object[] |  |
| refunds.items.id | integer |  |
| refunds.items.refund_id | integer |  |
| refunds.items.invoice_item_id | integer |  |
| refunds.items.catalog_item_id | integer \| null |  |
| refunds.items.name | string |  |
| refunds.items.price | string |  |
| refunds.items.count | integer | 0 при возврате по сумме (return_items[].amount). |
| refunds.items.amount | string |  |
| refunds.created_at | string |  |
| total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Счёт не найден

---

### POST /invoices/status/check

Возвращает актуальные статусы нескольких счетов организации за один запрос.
`invoice_ids.*` не проверяется на существование (анти-энумерация) — несуществующие
и чужие ID молча не попадут в ответ. Скоуп — организация ключа.


**Request:**
```json
{
  "invoice_ids": [
    42,
    43,
    44
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| invoice_ids | array | Yes | ID счетов для проверки. Отправляйте не более 100 ID за запрос, крупные списки разбивайте на части. |

**Response 200:** Актуальные статусы

Content-Type: `application/json`

```json
{
  "invoices": [
    {
      "id": 42,
      "status": "paid",
      "kaspi_invoice_id": "13234689513",
      "amount": "5000.00",
      "error_message": null,
      "updated_at": "2026-01-10T12:00:00+00:00"
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| invoices | object[] |  |
| invoices.id | integer |  |
| invoices.status | string |  |
| invoices.kaspi_invoice_id | string \| null |  |
| invoices.amount | string |  |
| invoices.error_message | string \| null |  |
| invoices.updated_at | string |  |

**Errors:**
- `400` — Организация не верифицирована
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### Invoice Statuses

| Status | Description | Can Cancel | Can Refund |
|--------|-------------|------------|------------|
| `pending` | Awaiting payment | Yes | No |
| `processing` | Being created in Kaspi (async) | Yes | No |
| `cancelling` | Being cancelled (async) | No | No |
| `paid` | Paid by customer | No | Yes |
| `cancelled` | Manually cancelled | No | No |
| `expired` | Payment deadline passed | No | No |
| `error` | Finalized with a technical error | No | No |
| `partially_refunded` | Partially refunded | No | Yes (remaining) |

---

## Refunds

### GET /refunds

Все возвраты организации (по всем счетам) с фильтрацией. Пагинация — **плоская**
`{current_page, data, total}`. Даты в ОТВЕТЕ — UTC `+00:00`.

Окно `date_from`/`date_to` — по времени ОПЕРАЦИИ возврата, в зоне мерчанта
(Asia/Almaty): голая дата = календарные сутки целиком, `Y-m-d H:i` = точная минута.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| status[] | array | — | Фильтр по статусам возврата. |
| invoice_id | integer | — | Фильтр по ID счёта (должен существовать). |
| date_from | string | — | Начало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty). |
| date_to | string | — | Конец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from. |
| per_page | integer | 10 |  |
| page | integer | 1 |  |

**Response 200:** Список возвратов

Content-Type: `application/json`

```json
{
  "current_page": 1,
  "data": [
    {
      "id": 5,
      "invoice_id": 42,
      "qr_refund_session_id": null,
      "amount": "5000.00",
      "kaspi_refund_id": "REF-123",
      "kaspi_status": "completed",
      "status": "completed",
      "reason": null,
      "initiated_by": "api",
      "error_message": null,
      "error_code": "idempotency_conflict",
      "created_at": "2026-01-01T12:00:00+05:00",
      "invoice": {
        "id": 42,
        "external_order_id": null,
        "amount": "15000.00",
        "status": "paid",
        "kaspi_invoice_id": null
      },
      "items": [
        {
          "id": 0,
          "refund_id": 0,
          "invoice_item_id": 0,
          "catalog_item_id": null,
          "name": "",
          "price": "",
          "count": 0,
          "amount": ""
        }
      ]
    }
  ],
  "total": 25
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.invoice_id | integer |  |
| data.qr_refund_session_id | integer \| null | ID связанной QR-сессии после сверки идентификатора операции при синхронизации. Используйте для исключения повторного учёта. |
| data.amount | string |  |
| data.kaspi_refund_id | string \| null |  |
| data.kaspi_status | string \| null |  |
| data.status | string |  |
| data.reason | string \| null |  |
| data.initiated_by | string | api / dashboard / system. |
| data.error_message | string \| null |  |
| data.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| data.created_at | string |  |
| data.invoice | object |  |
| data.invoice.id | integer |  |
| data.invoice.external_order_id | string \| null |  |
| data.invoice.amount | string |  |
| data.invoice.status | string |  |
| data.invoice.kaspi_invoice_id | string \| null |  |
| data.items | object[] |  |
| data.items.id | integer |  |
| data.items.refund_id | integer |  |
| data.items.invoice_item_id | integer |  |
| data.items.catalog_item_id | integer \| null |  |
| data.items.name | string |  |
| data.items.price | string |  |
| data.items.count | integer | 0 при возврате по сумме (return_items[].amount). |
| data.items.amount | string |  |
| total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### Refund Statuses

| Status | Description |
|--------|-------------|
| `pending` | Refund created, queued for processing |
| `processing` | Being processed by Kaspi |
| `completed` | Successfully refunded |
| `failed` | Refund failed |

---

## Catalog

### GET /catalog/units

Список единиц измерения для товаров. Получите перед созданием товаров, чтобы использовать корректные unit_id.

**Response 200:** Единицы измерения

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 1,
      "name": "штука",
      "name_kaz": "дана"
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.name | string |  |
| data.name_kaz | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден

---

### GET /catalog

Товары каталога. **Четыре режима чтения** (по приоритету):
1. **targeted** — `ntins[]`/`barcodes[]`/`ids[]`/`external_refs[]` (OR, все статусы,
   суммарно ≤200 значений по 4 наборам) → форма `{data:[...]}` без пагинации
   (точечное подтверждение отправленного набора);
2. **incremental** — `updated_after` → инкрементальный экспорт изменённого;
3. **keyset** — `cursor` (курсорная пагинация без deep-offset, для экспорта 100k+);
4. **default (offset)** — постранично `page`/`per_page`.

**Статусы (default = `active`):** без параметра `statuses[]` ЛЮБОЙ режим отдаёт
только `active` — позиции, которые есть в каталоге и над которыми не висит
незакрытая операция создания/снятия. Остальные четыре значения запрашиваются явно
(в т.ч. в incremental: `?updated_after=…&statuses[]=active&statuses[]=deleted`).
Запрос без параметров и `?statuses[]=active` дают ОДИН И ТОТ ЖЕ набор: скрытых осей
у default нет.

`status` — **производное** поле: оно вычисляется из состояния строки, а не хранится.
Приоритет веток фиксирован (первое совпадение выигрывает):
`failed` (операция брошена) → `pending` (открыто создание) → `deleting` (открыто
снятие) → `deleted` (позиция снята) → `active`. Открытая операция проверяется ВЫШЕ
состояния позиции. Поэтому снятая позиция со следом брошенной операции читается как
`failed`, а снятая позиция, для которой уже открыто повторное создание, — как
`pending`: она создаётся заново (в каталоге Kaspi её сейчас нет), и вид намерения
виден в `operation`. Продавать её при этом можно — `sellable=true`, как и у любой
позиции, создание которой В РАБОТЕ. ⚠️ Если создание брошено после исчерпания попыток
(`status=failed`), позиция НЕ продаётся: `sellable=false` — подтверждения Kaspi не
будет, пока вы не отправите `PATCH /catalog/{id}`.

Ось операции отдаётся отдельно и ортогональна статусу:
`operation=create|update|delete|null` плюс `error_code`/`error_message` при отказе.
Продаваемость и наличие в каталоге Kaspi — отдельные булевы поля `sellable` и
`in_kaspi_catalog` (см. схему ответа), выводить их из `status` не нужно.

⛔ Фильтр `batch_id` удалён вместе с агрегатом партий и **отклоняется явно**
(`422 catalog_batch_filter_removed`): молчаливый игнор вернул бы весь каталог вместо
позиций партии. Подтверждайте свой набор targeted-запросом по `external_refs[]`.

**Призраки НЕ отдаются никогда:** строки `state='void'` (позиции, которых никогда
не было в Kaspi) исключаются во всех режимах и не являются допустимым фильтром.

**Лимиты targeted (явная ошибка вместо тихого усечения):** суммарное число значений
по `ntins[]`+`barcodes[]`+`ids[]`+`external_refs[]` ≤200, и число найденных СТРОК
соответствий ≤1000. Превышение любого → `422` с `error_code: catalog_match_overflow`.

Режимы 2–4 отдают **meta-обёртку** `{data, links, meta}` (в keyset `meta` курсорная:
`next_cursor`/`prev_cursor`, без `total`). Только для организаций с каталогом
(иначе `400`/`404`). Даты — UTC `+00:00`.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| statuses[] | array | — | Фильтр по производному статусу позиции. Не передан → отдаётся только `active` (default) во всех режимах, включая targeted. Значения взаимоисключающие и покрывают весь каталог. Позиции, которых никогда не было в Kaspi, не отдаются ни при каком фильтре. |
| ntins[] | array | — | Targeted: НТИН'ы (≤200). |
| barcodes[] | array | — | Targeted: штрихкоды (≤200). |
| ids[] | array | — | Targeted: ID товаров (≤200). |
| external_refs[] | array | — | Targeted: клиентские ссылки external_ref (≤200). |
| updated_after | string | — | Incremental: только изменённое после даты (вкл. удалённые). |
| cursor | string | — | Keyset: курсор пагинации (из meta.next_cursor). |
| search | string | — | Поиск по названию. |
| barcode | string | — | Точный фильтр по штрихкоду. |
| first_char | string | — | Фильтр по первой букве названия. |
| without_ntin | boolean | false | `true` → только позиции без НТИН (`ntin` = null), независимо от наличия штрихкода — шире, чем поле ответа `ntin_missing` (то требует непустой `barcode`). Удобно считать «сколько осталось доделать» по `meta.total`. Компонуется со всеми режимами и фильтрами (`statuses[]`, `search` и т.д.). |
| source | string | — | `own` → только позиции, заведённые вами. По умолчанию параметр НЕ передаётся и список содержит ВЕСЬ каталог мерчанта — включая заведённое им самим и другими его интеграциями; чьё что, видно в поле `source` каждой позиции. Так и задумано: каталог принадлежит мерчанту, а «пустой» список толкнул бы вас залить всё заново и породить дубли. Других значений параметр не принимает. |
| per_page | integer | 50 |  |
| page | integer | 1 |  |

**Response 200:** Товары каталога (форма зависит от режима)

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 1,
      "kaspi_item_id": null,
      "name": "Coffee Latte",
      "unit_id": 1,
      "selling_price": 1800,
      "date_added": null,
      "image_url": null,
      "first_char": null,
      "nds_percentage": null,
      "barcode": "4901234567890",
      "ntin": null,
      "gtin": null,
      "unified_goods_id": null,
      "external_ref": null,
      "ntin_missing": false,
      "source": "own",
      "matched_existing": false,
      "name_differs": false,
      "outcome": "created",
      "status": "active",
      "sellable": false,
      "in_kaspi_catalog": false,
      "operation": "create",
      "error_message": null,
      "error_code": "idempotency_conflict",
      "created_at": "2026-01-01T12:00:00+05:00",
      "synced_at": "2026-01-01T12:00:00+05:00"
    }
  ],
  "links": {
    "first": null,
    "last": null,
    "prev": null,
    "next": null
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 2,
    "path": "",
    "per_page": 50,
    "to": 50,
    "total": 75
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.kaspi_item_id | string \| null |  |
| data.name | string |  |
| data.unit_id | integer \| null |  |
| data.selling_price | number |  |
| data.date_added | string \| null |  |
| data.image_url | string \| null |  |
| data.first_char | string \| null |  |
| data.nds_percentage | number \| null |  |
| data.barcode | string \| null |  |
| data.ntin | string \| null | НТИН Нацкаталога. |
| data.gtin | string \| null | GTIN (листинг Kaspi его не отдаёт, sync не затирает). |
| data.unified_goods_id | string \| null | Нацкаталог master-good id. |
| data.external_ref | string \| null | Клиентская ссылка (1С). |
| data.ntin_missing | boolean | true, если у товара есть barcode, но нет НТИН → не попадёт в фискальный чек как маркированный. Notify-only: дорезолвите НТИН через POST /catalog/scan + PATCH. Присутствует во всех ответах каталога. |
| data.source | string | Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`. |
| data.matched_existing | boolean | Только в ответе POST /catalog: позиция сматчена с уже существующим товаром (match-and-merge) — вернулся его живой id, новая строка не создавалась. В остальных ответах — false. ⚠️ Сматчиться можно и с ЧУЖОЙ позицией (`source: other`) — тогда ни имя, ни цена не изменятся. |
| data.name_differs | boolean | Только в ответе POST /catalog при matched_existing=true по ярусу 3 (совпал barcode/НТИН, но наименование другое): имя существующего товара НЕ перезаписано. Правьте имя явным PATCH /catalog/{id}. |
| data.outcome | string \| null | Что сделано с позицией. Только в ответе POST /catalog, в остальных ответах каталога null. `created` — заведена новая строка; `matched` — сматчилась существующая, правка применена или поставлена в очередь; `reissued` — переиздана снятая позиция; `unchanged` — строка уже совпадает с каталогом Kaspi, работа не нужна; `revived` — брошенное создание открыто заново; `not_started` — брошенное создание найдено, но работа НЕ открыта (дословный повтор уже отбитого), чините PATCH /catalog/{id}. ⚠️ Читайте именно это поле: matched_existing говорит лишь «строка найдена» и не отличает открытую работу от её отсутствия.
 |
| data.status | string | Производный статус позиции (вычисляется из её состояния, не хранится): `active` — в каталоге, незакрытых create/delete нет; `pending` — создаётся, в каталоге Kaspi её сейчас нет; `deleting` — снимается; `deleted` — снята; `failed` — операция над позицией брошена после исчерпания попыток. Значения взаимоисключающие, приоритет веток фиксирован (failed → pending → deleting → deleted → active): открытая операция проверяется выше состояния позиции, поэтому снятая позиция с открытым повторным созданием читается как `pending`, а не как `deleted`. Вид самой операции — в `operation`. |
| data.sellable | boolean | Примут ли позицию в корзину счёта / QR / подписки. `false` у снятой позиции и у позиции с открытым намерением удаления — даже если попытки удаления прекращены; повторный `POST /catalog` или `PATCH` возвращает её в продажу. Позиция, создание которой в работе (`status=pending`), продаётся: счёт по ней уйдёт разовой продажей, пока Kaspi не подтвердит создание — см. `in_kaspi_catalog`. ⚠️ `false` и у позиции, создание которой БРОШЕНО (`status=failed`, `operation=create`): её создание в каталоге Kaspi не подтверждено и подтверждено уже не будет. Надёжно возвращает такую позицию в продажу только `PATCH /catalog/{id}`: повторный `POST /catalog` возобновляет брошенное создание не всегда — условия перечислены в описании `POST /catalog`. |
| data.in_kaspi_catalog | boolean | Есть ли у позиции боевая идентичность номенклатуры Kaspi. `false` — счёт создастся, но позиция уедет разовой продажей: маркировка Нацкаталога в фискальный чек по ней не проводится. ⚠️ Это НЕ то же самое, что `sellable`: позиция может продаваться без каталожной идентичности и наоборот. ⚠️ **В песочнице флаг всегда `false`** — песочные позиции носят синтетическую идентичность номенклатуры, которой в Kaspi не существует. Это свойство тестового контура, а не предсказание для боевой позиции: маркировку проверяют только на боевой оси организации, `false` в песочнице поломкой не является. |
| data.operation | string \| null | Единственное не закрытое намерение над позицией. null — намерения нет; create/update/delete — заявленная операция. При отказе значение сохраняется вместе с error_code, error_message и failed_at. ⛔ Любая позиция с operation=delete не принимается в корзину, даже если попытки удаления прекращены; повторный POST/PATCH заменяет намерение и возвращает позицию в работу. |
| data.error_message | string \| null |  |
| data.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| data.created_at | string |  |
| data.synced_at | string | = updated_at. |
| links | object | Ссылки пагинации (meta-обёртка). |
| links.first | string \| null |  |
| links.last | string \| null |  |
| links.prev | string \| null |  |
| links.next | string \| null |  |
| meta | object | Метаданные пагинации (offset-режим). В keyset-режиме (`?cursor=`) вместо
`total`/`from`/`to` приходят `next_cursor`/`prev_cursor`.
 |
| meta.current_page | integer |  |
| meta.from | integer \| null |  |
| meta.last_page | integer |  |
| meta.path | string |  |
| meta.per_page | integer |  |
| meta.to | integer \| null |  |
| meta.total | integer |  |

**Errors:**
- `400` — Каталог не включён для организации
- `401` — X-API-Key отсутствует или невалиден
- `404` — Верифицированная организация не найдена
- `422` — Targeted-режим: суммарно значений >200 ИЛИ строк соответствий >1000 (`catalog_match_overflow`); неизвестное значение `statuses[]`; удалённый фильтр `batch_id` (`catalog_batch_filter_removed`).

---

### POST /catalog

Создаёт 1–100 товаров пакетно. Ответ всегда `202` — «принято»: в песочнице позиция
возвращается уже активной, в бою она получает статус `pending` и уезжает в Kaspi
фоновой обработкой. `external_ref` — клиентская ссылка (например код 1С) для
последующего точечного чтения (`?external_refs[]=`).

**Валидация per-item lenient.** Каждая позиция валидируется отдельно: валидные
обрабатываются штатно (`data[]`), невалидные попадают в `rejected[]` (см. ниже) и
НЕ роняют весь запрос. Одна битая позиция больше не даёт `422` на весь батч. `422`
остаётся ТОЛЬКО за структурными ошибками ЗАПРОСА: `items` отсутствует / не массив /
пуст / больше лимита (100). Ключ `rejected[]` присутствует в ответе ВСЕГДА (пустой
`[]`, когда все позиции валидны). Если ВСЕ позиции невалидны — ответ всё равно
успешный (`202`) с пустым `data[]` и полным `rejected[]`.

**Match-and-merge работает и в песочнице** — это тот же обход решений, что в бою, с
теми же маркерами; отличается только доставка: без обращения к Kaspi и синхронно.
Позиция возвращается сразу активной, переиздание удалённого по `external_ref`
активируется мгновенно (без фазы `pending`).

**Match-and-merge (идемпотентная заливка).** Если позиция совпадает с уже
существующим товаром, вместо дубля/ошибки возвращается ЖИВОЙ id существующего
товара с маркером `matched_existing: true` (id стабилен). Ярусы матча (по
приоритету): (1) совпал `external_ref` → полный update имени+цены + дозаполнение
пустых `ntin`/`gtin`/`barcode`; (2) совпал `barcode` ИЛИ `ntin` И совпало имя →
update цены + дозаполнение пустых; (3) совпал `barcode` ИЛИ `ntin`, но имя другое
(например разные размеры под одним штрихкодом — Kaspi считает это одним товаром) →
матч БЕЗ перезаписи имени/цены, маркер `name_differs: true` (правьте имя явным
`PATCH /catalog/{id}`). `external_ref` существующего товара НИКОГДА не
перетирается. Повторная заливка того же каталога идемпотентна: все позиции
вернутся `matched_existing: true`, новые строки не создаются. Дозаполненные поля
уезжают в Kaspi асинхронно. **Используйте `external_ref` как ключ маппинга** (не
barcode/имя): на один barcode у мерчанта может быть несколько товаров.

**Переиздание снятого / операция матча по `external_ref`:** если `external_ref`
указывает на снятый товар — он автоматически переиздаётся: ТА ЖЕ строка (тот же
`id`) получает `operation: create` и заново отправляется в Kaspi. ⚠️ До подтверждения
Kaspi она читается как `status: pending` — она создаётся заново, и в каталоге Kaspi
её пока нет, но в счёт её ставить уже можно (`sellable=true`, как у любой позиции,
создание которой в работе); вид намерения виден в `operation`. ⚠️ Брошенное создание
(`status: failed`) продажу блокирует — `sellable=false`.

**Что сделано с позицией — поле `outcome`.** `matched_existing: true` говорит только
«мы нашли вашу строку» и НЕ говорит, открылась ли по ней работа. Что именно
произошло, читайте в `outcome`: `created` — заведена новая строка; `matched` —
сматчилась существующая, правка применена или поставлена в очередь; `reissued` —
переиздана снятая позиция; `unchanged` — строка уже совпадает с каталогом Kaspi,
работа не нужна; `revived` — брошенное создание открыто заново; `not_started` —
брошенное создание найдено, но работа НЕ открыта. Поле аддитивное: в остальных
ответах каталога оно `null`.

**Повторная заливка брошенного создания (`status: failed`).** Она открывает работу
заново (`outcome: revived`, `status` снова `pending`) в двух случаях: прошлый отказ
не был отказом Kaspi по существу позиции (проблемы связи и авторизации) — либо вы
прислали позицию с изменённым наименованием, ценой, штрихкодом,
НТИН или GTIN, то есть исправили то, из-за чего она отбилась. Дословный повтор того же
тела работу НЕ открывает: ответ придёт с `outcome: not_started`, прежними
`status: failed`, `error_code` и `failed_at`, и Kaspi не будет позван.
Чтобы дожать такую позицию принудительно, используйте `PATCH /catalog/{id}` (он же
«Повторить» в кабинете): он исполняет сохранённую `operation` всегда, независимо от
причины отказа.

**Наименование позиции уникально в организации.** Kaspi не допускает двух позиций с
одинаковым наименованием (регистр и пробелы в конце не различаются, «ё» и «е» — разные
буквы, штрихкод роли не играет). Позиция, чьё наименование уже занято ДРУГИМ товаром
вашего каталога, в Kaspi не отправляется и получает `error_code:
catalog_item_duplicate` (строка, `GET /catalog/errors`, событие
`catalog.item_processed`). Тот же товар (тот же штрихкод/НТИН, либо оба без
идентификаторов при единственном товаре с этим наименованием) по-прежнему связывается
с существующей позицией. Две одноимённые позиции одной заливки не создаются вместе:
вторая получает исход после первой.

⚠️ Если вы шлёте `Idempotency-Key`, повтор ОБЯЗАН идти с НОВЫМ ключом:
точный повтор тела со старым ключом отвечает `200` + `idempotent_replay: true` и до
матчинга не доходит вовсе — ни реанимации, ни отказа вы не увидите.


**Request:**
```json
{
  "idempotency_key": null,
  "items": [
    {
      "name": "Coffee Latte",
      "selling_price": 1800,
      "unit_id": 1,
      "image_id": null,
      "barcode": null,
      "ntin": null,
      "gtin": null,
      "external_ref": null,
      "from_catalog": false
    }
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| idempotency_key | string | No | Ключ идемпотентности (эквивалент заголовка Idempotency-Key). Точный повтор того же тела → `200` с `idempotent_replay: true`. Пространство ключей ОБЩЕЕ с массовым удалением: другое тело или операция отдаёт `409 idempotency_key_conflict`. |
| items | array | Yes |  |

**Response 200:** Точный повтор Idempotency-Key: запрос не выполнен снова

Content-Type: `application/json`

```json
{
  "data": [],
  "rejected": [],
  "idempotent_replay": true
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.kaspi_item_id | string \| null |  |
| data.name | string |  |
| data.unit_id | integer \| null |  |
| data.selling_price | number |  |
| data.date_added | string \| null |  |
| data.image_url | string \| null |  |
| data.first_char | string \| null |  |
| data.nds_percentage | number \| null |  |
| data.barcode | string \| null |  |
| data.ntin | string \| null | НТИН Нацкаталога. |
| data.gtin | string \| null | GTIN (листинг Kaspi его не отдаёт, sync не затирает). |
| data.unified_goods_id | string \| null | Нацкаталог master-good id. |
| data.external_ref | string \| null | Клиентская ссылка (1С). |
| data.ntin_missing | boolean | true, если у товара есть barcode, но нет НТИН → не попадёт в фискальный чек как маркированный. Notify-only: дорезолвите НТИН через POST /catalog/scan + PATCH. Присутствует во всех ответах каталога. |
| data.source | string | Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`. |
| data.matched_existing | boolean | Только в ответе POST /catalog: позиция сматчена с уже существующим товаром (match-and-merge) — вернулся его живой id, новая строка не создавалась. В остальных ответах — false. ⚠️ Сматчиться можно и с ЧУЖОЙ позицией (`source: other`) — тогда ни имя, ни цена не изменятся. |
| data.name_differs | boolean | Только в ответе POST /catalog при matched_existing=true по ярусу 3 (совпал barcode/НТИН, но наименование другое): имя существующего товара НЕ перезаписано. Правьте имя явным PATCH /catalog/{id}. |
| data.outcome | string \| null | Что сделано с позицией. Только в ответе POST /catalog, в остальных ответах каталога null. `created` — заведена новая строка; `matched` — сматчилась существующая, правка применена или поставлена в очередь; `reissued` — переиздана снятая позиция; `unchanged` — строка уже совпадает с каталогом Kaspi, работа не нужна; `revived` — брошенное создание открыто заново; `not_started` — брошенное создание найдено, но работа НЕ открыта (дословный повтор уже отбитого), чините PATCH /catalog/{id}. ⚠️ Читайте именно это поле: matched_existing говорит лишь «строка найдена» и не отличает открытую работу от её отсутствия.
 |
| data.status | string | Производный статус позиции (вычисляется из её состояния, не хранится): `active` — в каталоге, незакрытых create/delete нет; `pending` — создаётся, в каталоге Kaspi её сейчас нет; `deleting` — снимается; `deleted` — снята; `failed` — операция над позицией брошена после исчерпания попыток. Значения взаимоисключающие, приоритет веток фиксирован (failed → pending → deleting → deleted → active): открытая операция проверяется выше состояния позиции, поэтому снятая позиция с открытым повторным созданием читается как `pending`, а не как `deleted`. Вид самой операции — в `operation`. |
| data.sellable | boolean | Примут ли позицию в корзину счёта / QR / подписки. `false` у снятой позиции и у позиции с открытым намерением удаления — даже если попытки удаления прекращены; повторный `POST /catalog` или `PATCH` возвращает её в продажу. Позиция, создание которой в работе (`status=pending`), продаётся: счёт по ней уйдёт разовой продажей, пока Kaspi не подтвердит создание — см. `in_kaspi_catalog`. ⚠️ `false` и у позиции, создание которой БРОШЕНО (`status=failed`, `operation=create`): её создание в каталоге Kaspi не подтверждено и подтверждено уже не будет. Надёжно возвращает такую позицию в продажу только `PATCH /catalog/{id}`: повторный `POST /catalog` возобновляет брошенное создание не всегда — условия перечислены в описании `POST /catalog`. |
| data.in_kaspi_catalog | boolean | Есть ли у позиции боевая идентичность номенклатуры Kaspi. `false` — счёт создастся, но позиция уедет разовой продажей: маркировка Нацкаталога в фискальный чек по ней не проводится. ⚠️ Это НЕ то же самое, что `sellable`: позиция может продаваться без каталожной идентичности и наоборот. ⚠️ **В песочнице флаг всегда `false`** — песочные позиции носят синтетическую идентичность номенклатуры, которой в Kaspi не существует. Это свойство тестового контура, а не предсказание для боевой позиции: маркировку проверяют только на боевой оси организации, `false` в песочнице поломкой не является. |
| data.operation | string \| null | Единственное не закрытое намерение над позицией. null — намерения нет; create/update/delete — заявленная операция. При отказе значение сохраняется вместе с error_code, error_message и failed_at. ⛔ Любая позиция с operation=delete не принимается в корзину, даже если попытки удаления прекращены; повторный POST/PATCH заменяет намерение и возвращает позицию в работу. |
| data.error_message | string \| null |  |
| data.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| data.created_at | string |  |
| data.synced_at | string | = updated_at. |
| rejected | object[] |  |
| rejected.index | integer | Позиция в исходном массиве items (0-based). |
| rejected.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| rejected.error_message | string | Человекочитаемое «что случилось → что сделать». |
| rejected.errors | object \| null | Карта поле → сообщения (только для error_code=catalog_item_invalid). Для barcode_too_long отсутствует. |
| rejected.name | string \| null | Echo name из исходной позиции (если был). |
| rejected.barcode | string \| null | Echo barcode из исходной позиции (если был). |
| rejected.external_ref | string \| null | Echo external_ref из исходной позиции (если был). |
| idempotent_replay | boolean |  |

**Response 202:** Принято: валидные товары созданы (невалидные — в rejected[]). В бою status=pending, operation=create до фонового подтверждения; в песочнице операция уже закрыта (status=active, operation=null).

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 1,
      "kaspi_item_id": null,
      "name": "Coffee Latte",
      "unit_id": 1,
      "selling_price": 1800,
      "date_added": null,
      "image_url": null,
      "first_char": null,
      "nds_percentage": null,
      "barcode": "4901234567890",
      "ntin": null,
      "gtin": null,
      "unified_goods_id": null,
      "external_ref": null,
      "ntin_missing": false,
      "source": "own",
      "matched_existing": false,
      "name_differs": false,
      "outcome": "created",
      "status": "active",
      "sellable": false,
      "in_kaspi_catalog": false,
      "operation": "create",
      "error_message": null,
      "error_code": "idempotency_conflict",
      "created_at": "2026-01-01T12:00:00+05:00",
      "synced_at": "2026-01-01T12:00:00+05:00"
    }
  ],
  "rejected": [
    {
      "index": 2,
      "error_code": "idempotency_conflict",
      "error_message": "Позиция не прошла валидацию: The selling price must be at least 0.01. Исправьте указанные поля и отправьте позицию повторно.",
      "errors": null,
      "name": null,
      "barcode": null,
      "external_ref": null
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.kaspi_item_id | string \| null |  |
| data.name | string |  |
| data.unit_id | integer \| null |  |
| data.selling_price | number |  |
| data.date_added | string \| null |  |
| data.image_url | string \| null |  |
| data.first_char | string \| null |  |
| data.nds_percentage | number \| null |  |
| data.barcode | string \| null |  |
| data.ntin | string \| null | НТИН Нацкаталога. |
| data.gtin | string \| null | GTIN (листинг Kaspi его не отдаёт, sync не затирает). |
| data.unified_goods_id | string \| null | Нацкаталог master-good id. |
| data.external_ref | string \| null | Клиентская ссылка (1С). |
| data.ntin_missing | boolean | true, если у товара есть barcode, но нет НТИН → не попадёт в фискальный чек как маркированный. Notify-only: дорезолвите НТИН через POST /catalog/scan + PATCH. Присутствует во всех ответах каталога. |
| data.source | string | Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`. |
| data.matched_existing | boolean | Только в ответе POST /catalog: позиция сматчена с уже существующим товаром (match-and-merge) — вернулся его живой id, новая строка не создавалась. В остальных ответах — false. ⚠️ Сматчиться можно и с ЧУЖОЙ позицией (`source: other`) — тогда ни имя, ни цена не изменятся. |
| data.name_differs | boolean | Только в ответе POST /catalog при matched_existing=true по ярусу 3 (совпал barcode/НТИН, но наименование другое): имя существующего товара НЕ перезаписано. Правьте имя явным PATCH /catalog/{id}. |
| data.outcome | string \| null | Что сделано с позицией. Только в ответе POST /catalog, в остальных ответах каталога null. `created` — заведена новая строка; `matched` — сматчилась существующая, правка применена или поставлена в очередь; `reissued` — переиздана снятая позиция; `unchanged` — строка уже совпадает с каталогом Kaspi, работа не нужна; `revived` — брошенное создание открыто заново; `not_started` — брошенное создание найдено, но работа НЕ открыта (дословный повтор уже отбитого), чините PATCH /catalog/{id}. ⚠️ Читайте именно это поле: matched_existing говорит лишь «строка найдена» и не отличает открытую работу от её отсутствия.
 |
| data.status | string | Производный статус позиции (вычисляется из её состояния, не хранится): `active` — в каталоге, незакрытых create/delete нет; `pending` — создаётся, в каталоге Kaspi её сейчас нет; `deleting` — снимается; `deleted` — снята; `failed` — операция над позицией брошена после исчерпания попыток. Значения взаимоисключающие, приоритет веток фиксирован (failed → pending → deleting → deleted → active): открытая операция проверяется выше состояния позиции, поэтому снятая позиция с открытым повторным созданием читается как `pending`, а не как `deleted`. Вид самой операции — в `operation`. |
| data.sellable | boolean | Примут ли позицию в корзину счёта / QR / подписки. `false` у снятой позиции и у позиции с открытым намерением удаления — даже если попытки удаления прекращены; повторный `POST /catalog` или `PATCH` возвращает её в продажу. Позиция, создание которой в работе (`status=pending`), продаётся: счёт по ней уйдёт разовой продажей, пока Kaspi не подтвердит создание — см. `in_kaspi_catalog`. ⚠️ `false` и у позиции, создание которой БРОШЕНО (`status=failed`, `operation=create`): её создание в каталоге Kaspi не подтверждено и подтверждено уже не будет. Надёжно возвращает такую позицию в продажу только `PATCH /catalog/{id}`: повторный `POST /catalog` возобновляет брошенное создание не всегда — условия перечислены в описании `POST /catalog`. |
| data.in_kaspi_catalog | boolean | Есть ли у позиции боевая идентичность номенклатуры Kaspi. `false` — счёт создастся, но позиция уедет разовой продажей: маркировка Нацкаталога в фискальный чек по ней не проводится. ⚠️ Это НЕ то же самое, что `sellable`: позиция может продаваться без каталожной идентичности и наоборот. ⚠️ **В песочнице флаг всегда `false`** — песочные позиции носят синтетическую идентичность номенклатуры, которой в Kaspi не существует. Это свойство тестового контура, а не предсказание для боевой позиции: маркировку проверяют только на боевой оси организации, `false` в песочнице поломкой не является. |
| data.operation | string \| null | Единственное не закрытое намерение над позицией. null — намерения нет; create/update/delete — заявленная операция. При отказе значение сохраняется вместе с error_code, error_message и failed_at. ⛔ Любая позиция с operation=delete не принимается в корзину, даже если попытки удаления прекращены; повторный POST/PATCH заменяет намерение и возвращает позицию в работу. |
| data.error_message | string \| null |  |
| data.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| data.created_at | string |  |
| data.synced_at | string | = updated_at. |
| rejected | object[] | Позиции, не прошедшие per-item валидацию (присутствует всегда, может быть пустым). |
| rejected.index | integer | Позиция в исходном массиве items (0-based). |
| rejected.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| rejected.error_message | string | Человекочитаемое «что случилось → что сделать». |
| rejected.errors | object \| null | Карта поле → сообщения (только для error_code=catalog_item_invalid). Для barcode_too_long отсутствует. |
| rejected.name | string \| null | Echo name из исходной позиции (если был). |
| rejected.barcode | string \| null | Echo barcode из исходной позиции (если был). |
| rejected.external_ref | string \| null | Echo external_ref из исходной позиции (если был). |

**Errors:**
- `400` — Лимит каталога в тестовом режиме (sandbox) превышен
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `409` — `catalog_busy` — каталог занят другой операцией, повторите через несколько секунд.
`idempotency_key_conflict` — этот `Idempotency-Key` уже занят другим телом либо
другой каталожной операцией. Пространство ключей приёма и массового удаления
общее; возьмите новый ключ. Повторять изменённый запрос с тем же ключом бесполезно.

- `422` — Ошибка валидации
- `502` — Ошибка Kaspi API
- `503` — Kaspi сессия истекла

---

### POST /catalog/upload-image

Загружает изображение товара. **Только JPEG и PNG**, максимум 6 МБ, стороны
64…6000 px, площадь до 12 Мпикс.
Тип определяется по содержимому файла, а не по имени и не по `Content-Type`:
файл с расширением `.png`, но иным содержимым, отклоняется (`422 invalid_file_type`).

Изображение **перекодируется на нашей стороне** (приводится к JPEG ≤512×512), поэтому
байты на выходе не совпадают с загруженными. Дедупликация — по MD5 **результата**,
внутри организации: повторная загрузка того же изображения вернёт прежний `image_id`.

⚠️ Для изображений, загруженных до 01.08.2026, дедуп мог считаться по исходному файлу.
Повторная загрузка такого файла один раз не совпадёт и создаст новый `image_id`; прежний
продолжает работать, чистить ничего не нужно. Дедупликацию не стоит считать контрактом:
при обновлении обработчика изображений хеш может однократно измениться.

Лимиты: **60/мин + 2000/сутки** на ключ.

⚠️ Ранее принимались также gif/webp/bmp/svg — сужено 01.08.2026 (см. changelog).


**Request:** `multipart/form-data`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| image | file | Yes | Файл изображения: JPEG или PNG, макс 6144 KB (6 МБ), стороны 64…6000 px, площадь ≤12 Мпикс. |

**Response 200:** Изображение загружено

Content-Type: `application/json`

```json
{
  "image_id": "00000000-0000-4000-8000-000000000000"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| image_id | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `413` — Файл больше 6 МБ (`error_code: file_too_large`). Повтор без уменьшения файла бессмысленен.
- `422` — `invalid_file_type` — содержимое не JPEG/PNG; `image_rejected` — габариты вне
допустимого (стороны 64…6000 px, площадь ≤12 Мпикс) либо файл повреждён;
`validation_error` — поле `image` отсутствует.

- `429` — Превышен лимит загрузок (60/мин или 2000/сутки на ключ).
- `500` — `image_processing_unavailable` — обработка изображений временно недоступна,
повторите позже. Изображение при этом **не сохранено** — `image_id` не выдан,
повторять запрос безопасно.


---

### PATCH /catalog/{id}

Обновляет товар. Все поля опциональны — применяются только присланные (`filled`).
`external_ref` при обновлении **не принимается**. ⚠️ `ntin`/`gtin` затирают
идентичность Нацкаталога безвозвратно (её нельзя восстановить синком) — отправляйте
только при реальном изменении. Код ответа зависит от ПОЗИЦИИ, а не от режима
организации: правка позиции песочницы применена уже в момент ответа (`200`), правка
боевой позиции принята в работу (`202`). В production правка доставляется в Kaspi
уже после ответа, поэтому доставка может занять больше минуты — закладывайте это в
сценарий и не считайте `202` подтверждением. До подтверждения сохраняются `operation=update` и всё
намерение картинки, включая `image_id`/удаление; исход проверяйте через targeted
`GET /catalog`, `GET /catalog/queue`, `GET /catalog/errors` или per-item webhook.

⚠️ **`PATCH` по позиции с `operation=delete` ОТМЕНЯЕТ удаление** — запрос означает
«позиция мне нужна» и атомарно заменяет намерение на `create` либо `update`.
Уже отправленное снятие не создаёт отдельного окна отказа: фон приводит позицию к
правде Kaspi, а при состоявшемся снятии заново создаёт её с тем же локальным `id`.

⛔ **Если позиция уже снята (`status: deleted`), `PATCH` по ней вернёт `404`:**
выборка идёт только по живым позициям, и снятая для неё не существует. Заведите её
заново обычным `POST /catalog`: по `external_ref` вернётся та же строка (тот же
`id`); у позиции без `external_ref` в каталоге появится новая строка с новым `id`,
а если её штрихкод совпадает с другим живым товаром — присланная позиция сольётся
с ним. Пересоздание есть только у `POST /catalog`, у `PATCH` его нет.

⚠️ **`catalog_item_duplicate`:** `PATCH` работу по такой позиции откроет, но если её
наименование по-прежнему занято другим товаром каталога, она будет отклонена снова.
Переименуйте её (`name`); если это тот же товар — обновляйте существующую позицию.


**Request:**
```json
{
  "name": "",
  "selling_price": 0.01,
  "unit_id": 0,
  "image_id": "00000000-0000-4000-8000-000000000000",
  "is_image_deleted": false,
  "barcode": null,
  "ntin": "",
  "gtin": ""
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | No |  |
| selling_price | number | No |  |
| unit_id | integer | No |  |
| image_id | string (uuid) | No | Новый ID изображения (exists). |
| is_image_deleted | boolean | No | true — удалить изображение. |
| barcode | string | No |  |
| ntin | string | No | ⚠️ Затирает идентичность Нацкаталога — только при реальном изменении. |
| gtin | string | No | ⚠️ То же предупреждение, что и для ntin. |

**Response 200:** Sandbox: товар обновлён синхронно

Content-Type: `application/json`

```json
{
  "message": "Catalog item updated",
  "catalog_item_id": 0
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| catalog_item_id | integer |  |

**Response 202:** Production: обновление поставлено в очередь

Content-Type: `application/json`

```json
{
  "message": "Catalog item update queued",
  "catalog_item_id": 0
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| catalog_item_id | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Товар не найден. ⚠️ Сюда же приходит уже СНЯТАЯ позиция (`status: deleted`):
выборка идёт по живым позициям. Заводите её заново через `POST /catalog`.

- `409` — Позиция заведена ДРУГОЙ интеграцией этой организации (`error_code:
catalog_item_foreign_channel`). Позиция при этом видна: в списке она приходит с
`source: other`, а `POST /catalog` при совпадении `external_ref`/`barcode`/`ntin`
возвращает её `id` — поэтому отказ именно `409`, а не `404`. Изменить и снять её
можно только со стороны той интеграции либо из кабинета мерчанта. Позиций без
интеграции-автора (`source: shared`) это не касается.

- `422` — Ошибка валидации

---

### DELETE /catalog/{id}

Код ответа зависит от ПОЗИЦИИ, а не от режима организации. Позиция песочницы снята уже
в момент ответа (`200`) и переходит в `status=deleted`. Боевая позиция принята в
работу (`202`): она становится `status=deleting` с `operation=delete`, а после
подтверждения Kaspi переходит в `deleted` и операция закрывается.

⚠️ Снятие в обоих случаях **логическое**: позиция остаётся доступной чтением
(`GET /catalog?statuses[]=deleted`) и восстановима повторной заливкой по тому же
`external_ref` — вернётся ТА ЖЕ позиция с тем же `id`.

⚠️ **Ответ приходит раньше, чем товар исчезает из кассы.** Снятие доставляется в
Kaspi уже после ответа и при загруженной очереди займёт дольше минуты. Проверяйте
результат чтением `GET /catalog?statuses[]=deleted`, а не сразу после ответа.

⚠️ После временного сбоя позиция остаётся `status=deleting`; сервис вернётся к ней
сам. Если попытки исчерпаны, позиция читается как `status=failed` с сохранённым
`operation=delete`, `error_code`/`failed_at` — и по-прежнему не продаётся
(`sellable=false`).

⚠️ Неоднозначная торговая точка у одиночного удаления больше не даёт синхронный
`409`: запрос принят с `202`, а на строке появляется
`error_code=catalog_multi_tradepoint`. Массовая ручка сохраняет синхронный preflight
и `409`, потому что обязана отклонить весь разрушительный набор до клейма.


**Response 200:** Позиция песочницы: снята синхронно

Без тела ответа.

**Response 202:** Боевая позиция: снятие поставлено в очередь

Content-Type: `application/json`

```json
{
  "message": "Catalog item deletion queued",
  "catalog_item_id": 0
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| catalog_item_id | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Товар не найден
- `409` — Позиция заведена ДРУГОЙ интеграцией этой организации (`error_code:
catalog_item_foreign_channel`). Позиция при этом видна: в списке она приходит с
`source: other`, а `POST /catalog` при совпадении `external_ref`/`barcode`/`ntin`
возвращает её `id` — поэтому отказ именно `409`, а не `404`. Изменить и снять её
можно только со стороны той интеграции либо из кабинета мерчанта. Позиций без
интеграции-автора (`source: shared`) это не касается.


---

## Subscriptions

### GET /subscriptions

Подписки организации. Пагинация — **meta-обёртка** `{data, links, meta}`
(`meta.current_page`/`total`/`per_page`/`last_page`/`from`/`to`). Фильтры — точное
совпадение (не LIKE), между собой соединяются по «И». Сортировка фиксированная:
`created_at DESC`. Даты — UTC `+00:00`.

Каждый фильтр принимает **одно значение или массив**: `status=active` и
`status[]=active&status[]=paused` — законные формы, массив означает «любое из».
⚠️ Неизвестное значение `status` отдаёт `422` — список допустимых значений объявлен
здесь же.
`phone_number` нормализуется: `+7 (700) 123-45-67`, `77001234567` и `87001234567`
находят одну и ту же подписку.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| status | string | — | Точный фильтр по статусу. Одно значение либо массив (`status[]=`).
 |
| external_subscriber_id | string | — | Одно значение либо массив. |
| phone_number | string | — | Номер в любой форме — приводится к каноническому `8XXXXXXXXXX`.
Одно значение либо массив.
 |
| per_page | integer | 20 |  |
| page | integer | 1 |  |

**Response 200:** Список подписок

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 10,
      "subscriber_name": "Иван Иванов",
      "phone_number": "87001234567",
      "external_subscriber_id": "CLIENT-001",
      "buyer_bin": null,
      "amount": "5000.00",
      "cart_items": null,
      "description": null,
      "billing_period": "monthly",
      "billing_period_label": "Ежемесячно",
      "billing_day": 1,
      "billing_day_from_end": null,
      "billing_day_label": null,
      "billing_time": "13:00",
      "total_cycles": null,
      "cycles_paid": 0,
      "status": "active",
      "status_label": "Активна",
      "status_color": "green",
      "started_at": null,
      "next_billing_at": null,
      "next_billing_in_days": null,
      "next_billing_label": "через 3 дня",
      "paused_at": null,
      "cancelled_at": null,
      "failed_attempts": 0,
      "max_retry_attempts": 3,
      "bill_until_paid": false,
      "retry_interval_hours": 24,
      "grace_period_days": 3,
      "in_grace_period": false,
      "is_sandbox": false,
      "metadata": null,
      "created_at": "2026-01-10T12:00:00+00:00",
      "updated_at": "2026-01-01T12:00:00+05:00"
    }
  ],
  "links": {
    "first": null,
    "last": null,
    "prev": null,
    "next": null
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 2,
    "path": "",
    "per_page": 50,
    "to": 50,
    "total": 75
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.subscriber_name | string \| null |  |
| data.phone_number | string |  |
| data.external_subscriber_id | string \| null |  |
| data.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| data.amount | string |  |
| data.cart_items | object[] \| null |  |
| data.description | string \| null |  |
| data.billing_period | string |  |
| data.billing_period_label | string |  |
| data.billing_day | integer \| null |  |
| data.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| data.billing_day_label | string \| null |  |
| data.billing_time | string \| null | Время списания по Алматы. |
| data.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| data.cycles_paid | integer | Сколько оплат уже получено. |
| data.status | string |  |
| data.status_label | string |  |
| data.status_color | string |  |
| data.started_at | string \| null |  |
| data.next_billing_at | string \| null |  |
| data.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| data.next_billing_label | string \| null |  |
| data.paused_at | string \| null |  |
| data.cancelled_at | string \| null |  |
| data.failed_attempts | integer |  |
| data.max_retry_attempts | integer |  |
| data.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| data.retry_interval_hours | integer |  |
| data.grace_period_days | integer |  |
| data.in_grace_period | boolean |  |
| data.is_sandbox | boolean |  |
| data.metadata | object \| null |  |
| data.created_at | string |  |
| data.updated_at | string |  |
| links | object | Ссылки пагинации (meta-обёртка). |
| links.first | string \| null |  |
| links.last | string \| null |  |
| links.prev | string \| null |  |
| links.next | string \| null |  |
| meta | object | Метаданные пагинации (offset-режим). В keyset-режиме (`?cursor=`) вместо
`total`/`from`/`to` приходят `next_cursor`/`prev_cursor`.
 |
| meta.current_page | integer |  |
| meta.from | integer \| null |  |
| meta.last_page | integer |  |
| meta.path | string |  |
| meta.per_page | integer |  |
| meta.to | integer \| null |  |
| meta.total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### POST /subscriptions

Создаёт рекуррентную подписку. Сумму задают одним из двух способов:
**`amount`** (100–1 000 000) либо **`cart_items`** — тогда сумма считается сервером
по актуальным ценам каталога, а `amount` из тела игнорируется. Передать нужно
ровно что-то одно: без корзины `amount` обязателен, отказ приходит по полю `amount`.
Организация **с каталогом** может вести подписку и просто на сумму, без `cart_items`.
Обратное неверно: у организации **без каталога** `cart_items` дают `422` с
`error_code: catalog_not_supported`.
`bill_immediately=true` — первый счёт сразу; иначе по расписанию в `next_billing_at`.

⛔ **Сумма списания — только целые тенге.** Списание выставляется счётом на номер
телефона, поэтому дробная сумма (и голая `amount`, и итог `cart_items` после скидок)
уходит в статус `error` с `error_code: amount_must_be_whole_tenge` — вебхуком
`invoice.status_changed`, и так при КАЖДОМ списании. Проверьте суммы действующих
подписок и цены каталожных позиций, из которых считается итог.

⛔ **Позиция корзины с `operation=delete` отбивается `422`** — здесь и в
`PUT /subscriptions/{id}`; текст в `errors["cart_items.N.catalog_item_id"]`.
⚠️ На очередном СПИСАНИИ тот же гейт даёт не ошибку, а отсрочку: если позиция
уходит в снятие у уже работающей подписки, списание откладывается до следующей
попытки списания — счётчик неудач не растёт и подписка не уходит в grace period
из-за временного состояния. Верните позицию обычным `POST /catalog`.

⚠️ **Отложенное списание ничем не сигнализируется:** счёт за период не создаётся,
вебхука нет, `next_billing_at` не двигается. Списание пройдёт автоматически первой
же попыткой после того, как позиция вернётся в продажу — если деньги нужны раньше,
верните позицию сами.


**Request:**
```json
{
  "phone_number": "87001234567",
  "billing_period": "daily",
  "amount": 100,
  "billing_day": 1,
  "billing_day_from_end": null,
  "billing_time": null,
  "first_billing_at": "2026-01-01",
  "total_cycles": null,
  "description": "",
  "subscriber_name": "",
  "external_subscriber_id": "",
  "buyer_bin": "990101300123",
  "started_at": "2026-01-01",
  "max_retry_attempts": 1,
  "bill_until_paid": false,
  "retry_interval_hours": 1,
  "grace_period_days": 1,
  "metadata": {},
  "cart_items": [
    {
      "catalog_item_id": 0,
      "count": 1
    }
  ],
  "bill_immediately": false
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| phone_number | string | Yes |  |
| billing_period | string | Yes |  |
| amount | number | No | Обязателен, когда cart_items не передан. С корзиной игнорируется — сумму считает сервер. |
| billing_day | integer | No | День списания. У `monthly`, `quarterly`, `yearly` — число месяца (1–28). У `weekly` и `biweekly` — ДЕНЬ НЕДЕЛИ: 1 — понедельник … 7 — воскресенье, и значения больше 7 на этих периодах отклоняются (раньше они принимались и молча игнорировались). У `daily` не используется. Взаимоисключающе с `billing_day_from_end`. |
| billing_day_from_end | integer | No | Опора от конца месяца: 0 — последний день, 1 — предпоследний. Доступна только для `monthly`, `quarterly`, `yearly`. Взаимоисключающе с `billing_day`. |
| billing_time | string | No | Время списания по Алматы в формате ЧЧ:ММ. Допустимое окно — 06:00–22:00. По умолчанию 13:00. |
| first_billing_at | date | No | Дата первого списания по календарю Алматы. Несовместима с bill_immediately. Без неё первое списание — started_at плюс период. |
| total_cycles | integer | No | Сколько ОПЛАЧЕННЫХ списаний сделать за всю жизнь подписки. Пусто — бессрочно. Неоплаченная попытка цикл не расходует. |
| description | string | No | Уходит в Kaspi комментарием к счёту списания; Kaspi показывает покупателю только первые 60 символов. Макросы: `{today}` — дата выставления счёта (дд.мм.гггг, Алматы); `{month}`/`{year}` — месяц услуги и его год, только у автоплатежа по календарю (здесь — 422). Неизвестный макрос или другой регистр (`{Today}`) — 422. Длина проверяется по тексту после подстановки. |
| subscriber_name | string | No |  |
| external_subscriber_id | string | No |  |
| buyer_bin | string | No | ИИН/БИН покупателя — строка ровно из 12 цифр. Уходит в КАЖДЫЙ счёт подписки (для фискального чека Kaspi). Kaspi номер не проверяет, форма проверяется у нас (422 errors.buyer_bin). В вебхуках не отдаётся. |
| started_at | date | No | По умолчанию сегодня. |
| max_retry_attempts | integer | No | Сколько счетов выставить за период, если клиент не платит (по умолчанию 3). Затем льготный период и `expired`. Несовместимо с `bill_until_paid: true` — переданы вместе → 422 по этому полю. |
| bill_until_paid | boolean | No | Режим «выставлять, пока не оплатят». Не передано или `false` — прежнее поведение с потолком `max_retry_attempts`. `true` — подписка по неоплате не истекает: истёкший счёт перевыставляется сразу, пока не наступил момент следующего планового списания; счёт, отменённый не плательщиком, и ошибка счёта закрывают период без повтора; `error_code = client_not_found` отменяет подписку (`subscription.cancelled`, `reason: payer_error`). |
| retry_interval_hours | integer | No |  |
| grace_period_days | integer | No |  |
| metadata | object | No |  |
| cart_items | array | No | Опционален. Доступен только организации с каталогом; без каталога даёт 422 catalog_not_supported. |
| bill_immediately | boolean | No |  |

**Response 201:** Подписка создана

Content-Type: `application/json`

```json
{
  "message": "Subscription created",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `400` — organization_required / sandbox_subscription_limit
- `401` — X-API-Key отсутствует или невалиден
- `403` — Организация не верифицирована; либо `tariff_inactive` — подписка мерчанта на
ApiPay не активна (грейса нет, блок сразу после `expires_at`; тело несёт
`expires_at`). Остановить уже существующую подписку (`pause`/`cancel`) можно
и без действующего тарифа.

- `422` — Ошибка валидации

---

### GET /subscriptions/{id}

Подписка со статистикой (`stats`) и последним платежом (`last_payment`).

⚠️ Тело обёрнуто в `subscription` — так же, как у `POST /subscriptions`,
`PUT`, `pause`, `resume` и `cancel`. Читайте `body.subscription.id`, а не `body.id`.


**Response 200:** Данные подписки

Content-Type: `application/json`

```json
{
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00",
    "last_payment": null,
    "stats": {
      "total_payments": 6,
      "successful_payments": 5,
      "failed_payments": 0,
      "total_collected": "25000.00"
    }
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |
| subscription.last_payment | object \| null |  |
| subscription.last_payment.amount | string |  |
| subscription.last_payment.paid_at | string \| null |  |
| subscription.last_payment.status | string |  |
| subscription.stats | object | Пропущенные периоды (`status: skipped` в истории платежей) в счётчики не входят — это не попытки оплаты. |
| subscription.stats.total_payments | integer |  |
| subscription.stats.successful_payments | integer |  |
| subscription.stats.failed_payments | integer |  |
| subscription.stats.total_collected | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Подписка не найдена

---

### PUT /subscriptions/{id}

Все поля опциональны. `cart_items` на не-каталог организации → `422` с
`error_code: catalog_not_supported`. При передаче `cart_items` сумма пересчитывается
по актуальным ценам каталога, а `amount` из тела игнорируется.

**Снять корзину** и вернуть подписку на фиксированную сумму — передать
`"cart_items": null` **вместе с `amount`**. Сумма при этом обязательна: иначе подписка
осталась бы с числом, посчитанным по уже неактуальному составу. ⚠️ `null` и отсутствие
поля — разные намерения: если `cart_items` не передан вовсе, корзина не трогается.

⛔ Позиция корзины с `operation=delete` отбивается `422` так же, как при создании —
текст в `errors["cart_items.N.catalog_item_id"]`.


**Request:**
```json
{
  "amount": 100,
  "billing_day": 1,
  "billing_day_from_end": null,
  "billing_time": null,
  "total_cycles": null,
  "description": "",
  "subscriber_name": "",
  "external_subscriber_id": "",
  "buyer_bin": null,
  "max_retry_attempts": 1,
  "bill_until_paid": false,
  "retry_interval_hours": 1,
  "grace_period_days": 1,
  "metadata": {},
  "cart_items": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| amount | number | No | Обязателен, если этим же запросом снимают корзину (cart_items=null). |
| billing_day | integer | No | День списания. У `monthly`, `quarterly`, `yearly` — число месяца (1–28). У `weekly` и `biweekly` — ДЕНЬ НЕДЕЛИ: 1 — понедельник … 7 — воскресенье, и значения больше 7 на этих периодах отклоняются (раньше они принимались и молча игнорировались). У `daily` не используется. Взаимоисключающе с `billing_day_from_end`. |
| billing_day_from_end | integer | No | Опора от конца месяца: 0 — последний день, 1 — предпоследний. Доступна только для `monthly`, `quarterly`, `yearly`. Взаимоисключающе с `billing_day`. |
| billing_time | string | No | Время списания по Алматы в формате ЧЧ:ММ. Допустимое окно — 06:00–22:00. По умолчанию 13:00. |
| total_cycles | integer | No | Сколько ОПЛАЧЕННЫХ списаний сделать за всю жизнь подписки. Пусто — бессрочно. Неоплаченная попытка цикл не расходует. |
| description | string | No | Описание подписки. Уходит в Kaspi комментарием к счёту списания. ⚠️ Kaspi показывает покупателю только ПЕРВЫЕ 60 СИМВОЛОВ, остальное отбрасывает молча — вынесите в начало то, по чему плательщик узнает платёж. ⛔ Отклоняется (HTTP 422) изменённое описание длиннее 60 символов. Проверяется только ИЗМЕНЁННОЕ описание: правка других полей у подписки со старым длинным описанием проходит как раньше. Без описания счёт списания уходит с текстом «Оплата подписки №{id}», в песочнице — «Оплата подписки №{id} (песочница)». Макросы: `{today}` — дата выставления счёта (дд.мм.гггг, время Алматы); `{month}` — месяц услуги строчными («сентябрь») и `{year}` — его год, только у автоплатежа по календарю (у остальных подписок — HTTP 422). Подписка хранит шаблон, значения подставляются в каждый счёт списания при его выставлении. Неизвестный макрос или другой регистр (`{Today}`) — HTTP 422. Длина проверяется по тексту после подстановки. |
| subscriber_name | string | No |  |
| external_subscriber_id | string | No |  |
| buyer_bin | string | No | ИИН/БИН покупателя. Без ключа — не меняется, `null` — стирает. Действует на будущие счета. |
| max_retry_attempts | integer | No | Сколько счетов выставить за период при неоплате. Если у подписки итоговый `bill_until_paid` — `true` (из этого тела или уже сохранённый), переданное число → 422 по этому полю, даже равное текущему. `null` — «не менять». |
| bill_until_paid | boolean | No | Режим «выставлять, пока не оплатят» (см. `CreateSubscriptionRequest`). Смена режима в любую сторону обнуляет `failed_attempts`; включение возвращает подписку из льготного периода к выставлению счетов. У автоплатежа по календарю — 422. `null` — «не менять». |
| retry_interval_hours | integer | No |  |
| grace_period_days | integer | No |  |
| metadata | object | No |  |
| cart_items | array | No | Новый состав корзины. `null` — снять корзину и вести подписку на фиксированную сумму (тогда `amount` обязателен). Поле не передано — корзина не трогается. |

**Response 200:** Подписка обновлена

Content-Type: `application/json`

```json
{
  "message": "Subscription updated",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Подписка не найдена
- `422` — Ошибка валидации

---

### POST /subscriptions/{id}/pause

Приостанавливает active-подписку (останавливает биллинг).

**Response 200:** Подписка приостановлена

Content-Type: `application/json`

```json
{
  "message": "Subscription paused",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `400` — Нельзя приостановить (подписка не active)
- `401` — X-API-Key отсутствует или невалиден
- `404` — Подписка не найдена

---

### POST /subscriptions/{id}/resume

Возобновляет `paused` или `expired` подписку СВОЕЙ периодичностью: `billing_period` и
расписание сохраняются, `next_billing_at` пересчитывается от текущего момента,
пропущенные периоды не доначисляются. У истёкшей подписки обнуляется счётчик неудачных
попыток, поэтому первый же неоплаченный период не уводит её обратно в льготный период.
Исчерпавшую `total_cycles` подписку возобновить нельзя — создавайте новую.

**Response 200:** Подписка возобновлена

Content-Type: `application/json`

```json
{
  "message": "Subscription paused",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `400` — Нельзя возобновить (подписка не paused и не expired, либо исчерпала все оплаченные списания)
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Подписка не найдена

---

### POST /subscriptions/{id}/cancel

Отменяет active/paused-подписку безвозвратно (реактивации нет — создавайте новую).

**Response 200:** Подписка отменена

Content-Type: `application/json`

```json
{
  "message": "Subscription paused",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `400` — Нельзя отменить (неверный статус)
- `401` — X-API-Key отсутствует или невалиден
- `404` — Подписка не найдена

---

### GET /subscriptions/{id}/invoices

История платежей подписки: строка на каждый расчётный период — счёт подписки или
пропущенный период (`status: skipped`, бывает без счёта). **Нестандартный конверт**
`{data, meta}` — `meta`
содержит `current_page`/`total`/`per_page` (без `last_page`). Даты — UTC `+00:00`.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| per_page | integer | 20 |  |
| page | integer | 1 |  |

**Response 200:** Счета подписки

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 1,
      "invoice_id": 42,
      "billing_period_start": "2026-02-01",
      "billing_period_end": "2026-02-28",
      "billing_period_label": "01.02.2026 — 28.02.2026",
      "amount": "5000.00",
      "attempt_number": 1,
      "status": "paid",
      "status_label": "Оплачен",
      "status_color": "green",
      "paid_at": null,
      "failure_reason": null,
      "failure_code": "invoice_expired",
      "error_code": "client_not_found",
      "invoice": {
        "id": 42,
        "kaspi_invoice_id": null,
        "status": "paid"
      },
      "created_at": "2026-01-01T12:00:00+05:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "total": 3,
    "per_page": 20
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.invoice_id | integer \| null |  |
| data.billing_period_start | string |  |
| data.billing_period_end | string |  |
| data.billing_period_label | string |  |
| data.amount | string \| null | Сумма счёта; `null`, если счёт за период не выставлялся (`status: skipped` без счёта). |
| data.attempt_number | integer |  |
| data.status | string | `pending` — счёт выставлен и ждёт оплаты; `paid` — оплачен; `failed` — не оплачен
(причина в `failure_code`); `cancelled` — снят вместе с отменой подписки;
`skipped` — период закрыт без оплаты по решению продавца (например, покупатель
заплатил другим способом). Пропущенный период долгом не является и в `stats`
подписки не считается. Если счёт за период ещё не выставлялся, у строки нет счёта:
`invoice_id` и `amount` равны `null`, а поля `invoice` нет. Если счёт был выставлен,
ApiPay запрашивает его отмену в Kaspi; если покупатель всё же оплатит этот счёт,
строка перейдёт в `paid`.
 |
| data.status_label | string |  |
| data.status_color | string |  |
| data.paid_at | string \| null |  |
| data.failure_reason | string \| null | Причина неоплаты текстом для человека. Формат не контракт и различается между подписками — ветвитесь по `failure_code`. |
| data.failure_code | string \| null | Машинная причина неоплаты; `null`, пока платёж не `failed` (и у части записей,
созданных до 26.09.2026). `invoice_expired` — счёт истёк неоплаченным;
`invoice_cancelled` — счёт отменён (в том числе заменён новым); `payer_refused` —
покупатель явно отказался от счёта в Kaspi; `invoice_error` — счёт не выставился,
код ошибки — в `error_code`.
 |
| data.error_code | string \| null | Код ошибки выставления счёта (каталог `ErrorCode`); непуст только при `failure_code: invoice_error`. Тот же, что в вебхуке `subscription.payment_failed`. |
| data.invoice | object | Присутствует, если связанный счёт существует. |
| data.invoice.id | integer |  |
| data.invoice.kaspi_invoice_id | string \| null |  |
| data.invoice.status | string |  |
| data.created_at | string |  |
| meta | object |  |
| meta.current_page | integer |  |
| meta.total | integer |  |
| meta.per_page | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Подписка не найдена

---

### Subscription Statuses

| Status | Description |
|--------|-------------|
| `active` | Active, billing on schedule |
| `paused` | Temporarily paused |
| `cancelled` | Permanently cancelled |
| `completed` | Completed all billing cycles |
| `expired` | Expired after grace period |

### Billing Periods

| Period | Description |
|--------|-------------|
| `daily` | Daily |
| `weekly` | Weekly |
| `biweekly` | Biweekly |
| `monthly` | Monthly |
| `quarterly` | Quarterly |
| `yearly` | Yearly |

---

## Other API endpoints

### POST /invoices/bulk

Создаёт до 100 счетов одним запросом на **одного кассира** (`kaspi_connection_id`
общий на всю пачку; по умолчанию — primary). Доп. лимит **20/мин**.
Структурная ошибка тела (невалидная схема) → `422`
отклоняет весь батч атомарно; бизнес-ошибки отдельных позиций возвращаются
поэлементно в `invoices[]` (created/duplicate/failed), сам батч при этом `201`.

⚠️ **Неактивный тариф — исключение из поэлементной модели:** `tariff_inactive`
отбивает **весь запрос** `403`, а не приходит в `failed[]`. Причина не в конкретной
позиции, а в самой организации, поэтому запрос закрывается на входе.

⛔ **Сумма — только целые тенге** (как и в `POST /invoices`). Позиция с тиынами
приходит в `invoices[]` как `failed` с `error_code: amount_must_be_whole_tenge`;
остальные позиции батча создаются нормально.

⛔ **Товар в снятии тоже отбивается поэлементно.** Счёт с позицией корзины, над
которой открыто снятие (`operation=delete`, `sellable=false`), приходит в
`invoices[]` как `failed` с
`error_code: catalog_item_not_found` и текстом `Catalog item is being deleted…`
в `message`; остальные позиции батча создаются. Позиция восстановима — см.
`CartItem.catalog_item_id`.


**Request:**
```json
{
  "kaspi_connection_id": null,
  "invoices": [
    {
      "phone_number": "87001234567",
      "amount": null,
      "description": "",
      "internal_comment": null,
      "buyer_bin": "990101300123",
      "external_order_id": "",
      "external_order_id_idempotency": "",
      "cart_items": [
        {
          "catalog_item_id": 0,
          "count": 1,
          "price": null
        }
      ],
      "discount_percentage": 1
    }
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| kaspi_connection_id | integer | No | Кассир на всю пачку (default — primary). |
| invoices | array | Yes | Список счетов (1–100). |

**Response 201:** Батч обработан (см. поэлементные результаты). Отказ отдельной позиции не отменяет
остальные и не меняет код ответа — разбирайте `invoices[]` построчно.
Среди построчных `error_code` может прийти `field_too_long`: значение поля превышает
допустимую длину, эту позицию нужно повторить с укороченным значением,
соседние счета при этом уже созданы.


Content-Type: `application/json`

```json
{
  "created": 8,
  "duplicates": 1,
  "failed": 1,
  "invoices": [
    {
      "index": 0,
      "status": "created",
      "id": 0,
      "amount": "",
      "external_order_id": null,
      "phone": "",
      "invoice_id": 0,
      "existing_status": "",
      "error_code": "",
      "message": ""
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| created | integer |  |
| duplicates | integer |  |
| failed | integer |  |
| invoices | object[] |  |
| invoices.index | integer | Индекс позиции в исходном массиве. |
| invoices.status | string |  |
| invoices.id | integer | Только status=created. |
| invoices.amount | string | Только status=created. |
| invoices.external_order_id | string \| null | Только status=created. |
| invoices.phone | string | Только status=created. |
| invoices.invoice_id | integer | Только status=duplicate. |
| invoices.existing_status | string | Только status=duplicate. |
| invoices.error_code | string | Только status=failed. |
| invoices.message | string | Только status=failed. |

**Errors:**
- `400` — organization_required / sandbox_invoice_limit / not-verified / kaspi_session_not_configured
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `422` — Ошибка валидации
- `429` — Превышен антифрод/триал/тарифный лимит на создание счетов. `error_code` — один из
`trial_daily_limit`, `tariff_limit_reached` (лимит оплаченного тарифа; `meta.mode`
различает суточный потолок и бюджет 30-дневного блока), `outstanding_recipient_limit`,
`outstanding_org_limit`, `recipient_fanout_exceeded`, `kyc_daily_limit_reached`
(мерчант без одобренной анкеты — боевые счета закрыты, `meta.limit=0`; песочница
работает и анкеты не требует).
`recipient_fanout_exceeded` не содержит `Retry-After` и `retry_after_seconds`;
это защитное ограничение, причину разбирают через поддержку.
У остальных перечисленных кодов есть время ожидания, но KYC-ограничение
снимается одобрением анкеты, а не наступлением следующего окна.

- `503` — Создание счёта временно недоступно. `error` / `error_code`:
* `kaspi_session_invalid` — сессия кассира Kaspi истекла (переподключите кассира);
* `invoices_disabled` — приём новых счетов приостановлен (технические работы). Счёт
  НЕ создан — повторите позже. Чтение счетов, отмена, возврат и статусы продолжают
  работать. У `POST /invoices/qr` при приостановке запросов в Kaspi приходит ещё
  заголовок `Retry-After`; при полной остановке приёма счетов его нет.


---

### POST /invoices/qr

Два режима, без номера телефона:

- **QR-счёт для оплаты прямо сейчас** (по умолчанию). Счёт выпускается в Kaspi сразу;
  ответ несёт `qr_image_url` (показать QR на экране кассы) и `qr_token_url` — ссылку
  Kaspi на этот же QR-счёт: её можно открыть на телефоне покупателя вместо скана.
  Оба живут минуты — до `qr_expires_at` (см. ниже), отправлять их «на потом» нельзя.
- **Одноразовая ссылка на оплату** (`static: true`). Счёт не выпускается; ответ несёт
  `payment_url` — ссылку, которую отправляют покупателю в мессенджер или ставят кнопкой
  на сайте. Счёт появится, только когда покупатель нажмёт «Оплатить» на странице.
  Подробности — в описании `CreateQrInvoiceRequest` и `PaymentLinkCreated`.

**Синхронный**: ответ `201` сразу; у QR-счёта — со `status: "pending"` и QR-полями
(pending-вебхука для QR нет).

Жизненный цикл QR — **минуты**, не 24ч. `qr_expires_at` — момент, до которого
QR ещё можно **отсканировать**; длительность окна считайте из ответа, а не
константой. ⚠️ Окно ограничивает только скан: как только покупатель отсканировал
QR и попал на экран оплаты, операция живёт дольше, и оплата, начатая под конец
окна, завершится уже после `qr_expires_at`.
Терминальный статус (`paid`/`cancelled`/`expired`) ставит Kaspi и приносит вебхук —
**ориентируйтесь на него, а не на локальный отсчёт**. QR-счета **сосуществуют**: создание нового QR на той же кассе
НЕ отменяет прежние — старый остаётся `pending` и мониторится до своего терминала.
Реагируйте по каждому `invoice.id` отдельно. `cancelled` по QR = реальная отмена
клиентом. Когда клиент отсканировал QR — приходит событие `invoice.qr_scanned`
(`qr_substate: "scanned"`; статус счёта событие не меняет, `status` в payload —
текущий на момент отправки). Отмена QR-счёта не
поддерживается; возврат — через `POST /qr-refunds/links` (ссылка на возврат для
покупателя; немедленный `POST /qr-refunds` — deprecated).

⛔ **Позиция корзины с `operation=delete` отбивается `422`** —
`errors["cart_items.N.catalog_item_id"]`, отдельного `error_code` у этой ветки нет.
Как вернуть позицию — см. `CartItem.catalog_item_id`.

⛔ **Скидка на QR (`discount_percentage`) — по правилу Kaspi:** процент только целый
(1–99, дробный → `422` с `errors.discount_percentage`), скидка каждой строки =
`цена × количество × процент / 100`, округлённая **вверх до целого тенге**; итог =
сумма строк минус сумма скидок. На дешёвой позиции фактическая скидка поэтому больше
заявленной (7 % от 5 ₸ = 1 ₸) — так же считает приложение Kaspi. Сумму счёта берите
из ответа (`amount`, `discount_sum`), а не из своего расчёта. Скидка на позицию с
ценой в тиынах (`cart_items[].price` или цена каталога) и скидка, обнуляющая позицию
(1 ₸ со скидкой 10 % — это скидка 1 ₸), не принимаются — `422 qr_discount_unsupported`,
в песочнице тоже. Количество в строке корзины — не больше 100000.

⚠️ **На время технических работ ручка отвечает `503 invoices_disabled`, счёт НЕ
создаётся.** Копить QR нечем: он выпускается в Kaspi синхронно, и без ответа Kaspi
не существует ни `qr_token_url`, ни картинки. Если приостановлены только запросы в
Kaspi, в ответе есть `Retry-After`, а телефонные счета (`POST /invoices`,
`/invoices/bulk`) в это же время принимаются как обычно и отвечают `201 processing`.
Если остановлен приём счетов целиком, `503` отвечают и они (см. `POST /invoices`).


**Request:**
```json
{
  "static": false,
  "single_use": true,
  "expires_at": null,
  "amount": 5000,
  "description": "",
  "internal_comment": null,
  "buyer_bin": "990101300123",
  "external_order_id": "",
  "external_order_id_idempotency": "",
  "kaspi_connection_id": 0,
  "simulate": "paid",
  "cart_items": [
    {
      "catalog_item_id": 0,
      "count": 1,
      "price": null
    }
  ],
  "discount_percentage": 1
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| static | boolean | No | Создать одноразовую ссылку вместо немедленного QR-счёта. |
| single_use | boolean | No | В режиме static=true поддерживается только одна сделка. |
| expires_at | string | No | Необязательный срок ссылки в будущем; без него действует до оплаты или отключения. |
| amount | number | No |  |
| description | string | No | Наименование позиции в QR-чеке Kaspi (макс 100 — ограничение Kaspi). |
| internal_comment | string | No | Внутренняя заметка мерчанта; в Kaspi не уходит и плательщику не видна.
Ограничение `description` в 100 символов (лимит Kaspi на наименование позиции
в чеке) на это поле **не распространяется** — потолок 255, как в `POST /invoices`.
 |
| buyer_bin | string | No | ИИН/БИН покупателя — строка ровно из 12 цифр (числом не передавать: теряется ведущий ноль). Необязательный. Передаётся в Kaspi для фискального чека; Kaspi номер не проверяет, форма проверяется у нас (422 errors.buyer_bin). Не принимается при `static: true` (422). В вебхуках не отдаётся. |
| external_order_id | string | No |  |
| external_order_id_idempotency | string | No |  |
| kaspi_connection_id | integer | No |  |
| simulate | string | No | Только sandbox — сразу перевести созданный QR-счёт в статус. |
| cart_items | array | No |  |
| discount_percentage | integer | No | Целый процент 1–99 (дробный → 422 errors.discount_percentage). Скидка строки = ceil(цена × количество × процент / 100) в целых тенге. Со скидкой позиция с ценой в тиынах или обнулённая скидкой позиция → 422 qr_discount_unsupported (в песочнице тоже). |

**Response 201:** QR-счёт создан; при static=true создана одноразовая ссылка без счёта

Content-Type: `application/json`

```json
{
  "id": 1,
  "amount": "5000.00",
  "status": "pending",
  "paid_at": null,
  "phone": null,
  "buyer_bin": null,
  "is_imported": false,
  "created_at": "2026-01-10T12:00:00+00:00",
  "is_qr_token": true,
  "qr_token_url": "https://qr.kaspi.kz/...",
  "qr_image_url": null,
  "qr_expires_at": "2026-01-10T12:03:00+00:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| amount | string |  |
| status | string |  |
| paid_at | string \| null |  |
| phone | string \| null | null для QR-счёта без номера. |
| buyer_bin | string \| null | ИИН/БИН покупателя, если был передан (только у счёта, не у static-ссылки). |
| is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| created_at | string |  |
| is_qr_token | boolean |  |
| qr_token_url | string | Ссылка Kaspi на этот QR-счёт. Открывается в приложении Kaspi без сканирования:
подходит, чтобы открыть на телефоне покупателя, который платит прямо сейчас.
Действует до `qr_expires_at` (минуты), поэтому отправлять её «на потом» нельзя —
для этого есть `payment_url` (`static: true`). В песочнице (`is_sandbox: true`) ведёт на тестовую страницу
ApiPay (`https://qr.apipay.kz/sandbox/…`), а не в Kaspi: страница показывает сумму,
описание и статус счёта и объясняет, что счёт тестовый и оплатить его нельзя.
`qr_image_url` кодирует тот же адрес. Путь адреса не разбирайте — его формат не
входит в контракт.
 |
| qr_image_url | string \| null | PNG QR на нашем хранилище. Живёт до `qr_expires_at + 60 сек`, дальше `404` — перевыпустите QR, а не перезагружайте картинку. |
| qr_expires_at | string | Крайний момент, когда QR ещё можно ОТСКАНИРОВАТЬ. Длительность окна задаёт Kaspi — считайте её из этого поля, не константой. Начатая до него оплата завершается и позже. Терминальный статус диктует Kaspi — ждите вебхук. |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `static_qr_disabled` — выпуск ссылок на оплату на сервисе сейчас выключен (только
`static: true`): ссылка не создана, для отдельной организации режим не включается.
`kyc_rejected` — мерчант отклонён на KYC-модерации; `tariff_inactive` — подписка на
ApiPay не активна (продлите тариф в кабинете). Грейс-периода у тарифа нет —
блокировка наступает сразу после `expires_at`, который приходит в теле ответа.

- `409` — `duplicate_idempotency_key` (дубль ключа прямого QR); `idempotency_conflict`
(static=true: дубль ключа ссылки; ответ содержит static_qr_id и payment_url); `kaspi_session_expired` (сессия кассира
мертва — QR не создан, нужна переавторизация).

- `422` — Ошибка валидации
- `429` — `qr_rate_limit` (200/мин на организацию) — без `Retry-After` и без
`retry_after_seconds`; повторите примерно через минуту.
Cap-лимиты (`trial_daily_limit`/`tariff_limit_reached`/`outstanding_*`/
`kyc_daily_limit_reached`) возвращают `retry_after_seconds` и `Retry-After`.
У тарифного и KYC-ограничения также есть `meta`; действие зависит от кода
ограничения, одного ожидания для одобрения KYC недостаточно.

- `500` — qr_render_failed — не удалось отрендерить PNG QR (также шлётся вебхук error)
- `502` — kaspi_error — ошибка Kaspi при создании QR (также шлётся вебхук error)
- `503` — Создание счёта временно недоступно. `error` / `error_code`:
* `kaspi_session_invalid` — сессия кассира Kaspi истекла (переподключите кассира);
* `invoices_disabled` — приём новых счетов приостановлен (технические работы). Счёт
  НЕ создан — повторите позже. Чтение счетов, отмена, возврат и статусы продолжают
  работать. У `POST /invoices/qr` при приостановке запросов в Kaspi приходит ещё
  заголовок `Retry-After`; при полной остановке приёма счетов его нет.


---

### GET /invoices/stats

Агрегированная статистика счетов за период. Укажите **либо** `period`, **либо**
пару `start_date`+`end_date` (иначе `422`). `pending_*` включает счета в статусе
`processing`. Поля `total_collected` **нет** — сумма оплаченного это `paid_amount`.

Границы задаются в зоне мерчанта (Asia/Almaty): голая дата = календарные сутки
целиком, `Y-m-d H:i` = точная минута. По какому времени резать окно — выбирает
`date_field` (по умолчанию `created_at`).
Явный офсет уважается как указан; в query-строке `+` кодируется как `%2B`.

Ручка принимает те же фильтры, что и список: `status[]`, `search`, `date_field`,
`origin` — чтобы сводка совпадала с таблицей под ней. Границы периода при этом
задаются ТОЛЬКО парой `start_date`/`end_date` (или `period`): `date_from`/`date_to`
от списка здесь не применяются.

⚠️ **Фильтр сужает саму базу агрегата, а не выделяет строки в нём.** При
`status[]=paid` поля `cancelled_*` и `expired_*` станут нулями, а `conversion_rate` —
`100`. Это ожидаемо: вы спросили статистику по подмножеству.

⚠️ `date_field=paid_at` отсеивает неоплаченные счета — у них `paid_at` пуст. Конверсия
на таком окне тоже вырождается в `100`.

⚠️ `paid_amount`, `paid_invoices` и `conversion_rate` здесь считают только статус
`paid` и **не включают** `partially_refunded` — у мерчанта с частичными возвратами
конверсия визуально занижена. Опаснее всего это в паре с фильтром: запрос
`status[]=paid&status[]=partially_refunded` даст `total_invoices`, куда частично
возвращённые входят, и `paid_invoices`, куда они не входят, — то есть конверсию
заведомо ниже 100 % на выборке, где оплачены все. Для сверки кассы используйте
`GET /api/v1/cashbox/reconciliation` — там раскладка кассового отчёта и сверка
с цифрами Kaspi.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| period | string | — | Предустановленный период (взаимоисключимо с start_date/end_date). |
| start_date | string | — | Начало периода — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty). Требует end_date. |
| end_date | string | — | Конец периода включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= start_date. |
| status[] | array | — | Фильтр по статусам — те же значения, что у `GET /invoices`. ⚠️ Сужает базу
агрегата целиком: поля по неотобранным статусам станут нулями.
 |
| search | string | — | Поиск, тот же что у `GET /invoices` (описание, телефон, external_order_id и прочее). |
| date_field | string | created_at | По какому времени резать период.
`created_at` — момент выставления счёта (по умолчанию).
`paid_at` — момент оплаты; неоплаченные счета при этом отсеиваются.
 |
| origin | string | all | Происхождение счёта, та же ось, что у `GET /invoices`.
`all` (по умолчанию) / `apipay` (выставлено через ApiPay) / `kaspi` (проведено
в приложении Kaspi Pay мимо ApiPay и подтянуто из истории Kaspi).
 |

**Response 200:** Статистика

Content-Type: `application/json`

```json
{
  "total_invoices": 100,
  "paid_invoices": 75,
  "pending_invoices": 10,
  "cancelled_invoices": 10,
  "expired_invoices": 5,
  "total_amount": 500000,
  "paid_amount": 375000,
  "pending_amount": 50000,
  "cancelled_amount": 50000,
  "expired_amount": 25000,
  "conversion_rate": 75,
  "period": {
    "start": "2026-08-03T11:00:00+05:00",
    "end": "2026-08-04T03:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| total_invoices | integer |  |
| paid_invoices | integer |  |
| pending_invoices | integer | Включает processing. |
| cancelled_invoices | integer |  |
| expired_invoices | integer |  |
| total_amount | number |  |
| paid_amount | number | «Собрано» за период (аналога total_collected нет). |
| pending_amount | number |  |
| cancelled_amount | number |  |
| expired_amount | number |  |
| conversion_rate | number | paid/total*100, округл. до 2 знаков (0 если счетов нет). |
| period | object | Присутствует, когда диапазон резолвится. Эхо фактически применённых границ
в зоне мерчанта (ISO-8601 с офсетом `+05:00`).
 |
| period.start | string |  |
| period.end | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Не указан ни period, ни пара дат (или невалидный диапазон)

---

### GET /invoices/{id}/receipt

Ссылки на чек Kaspi по оплаченному счёту: `share_link` — то, что отдают
покупателю, `download_link` — прямой PDF (обе несут секретный `hash`),
`receipt_link` — фискальная форма чека, секрета не несёт.

Это чек Kaspi по оплате счёта. Фискальные чеки за наличные и POS другого банка —
отдельный раздел `/receipts` (Kaspi OFD), к этой ручке отношения он не имеет.

**Ответ асинхронный.** Первый вызов ставит задачу и отдаёт `202 {status:"pending"}` —
повторите запрос через `poll_after` секунд. Как только чек получен, тот же URL
отдаёт `200 {status:"ready"}`. Результат кэшируется: чек неизменен, поэтому
повторный вызов по тому же счёту отвечает сразу.

Доступно по счетам в статусе `paid` и `partially_refunded` (частичный возврат —
это по-прежнему оплаченный счёт). Тот же `409` приходит, если у оплаченного счёта
ещё нет числового идентификатора Kaspi. В песочнице ответ приходит сразу, и ссылки
ведут на тестовую страницу чека ApiPay: сумма, дата и позиции — из счёта, реквизиты
продавца и фискальные данные — заглушки «Тест тест», `download_link` — её PDF. В бою
ссылки всегда ведут на `receipt.kaspi.kz` — на хост песочных ссылок не завязывайтесь.

⚠️ `download_link` и `share_link` содержат секретный параметр `hash` — по нему
чек откроет кто угодно. Не публикуйте их и не кладите в логи. Выданную ссылку
отозвать нельзя: если она утекла, закрыть доступ нечем.

⚠️ У ручки отдельный минутный лимит — он строже общего.


**Response 200:** Чек получен

Content-Type: `application/json`

```json
{
  "status": "ready",
  "receipt_link": "https://receipt.kaspi.kz/web/fiscal?i=000000000000&f=000000000000&s=10&t=2026-01-10%2012%3A00%3A00.000000",
  "download_link": "https://receipt.kaspi.kz/api/v3/receipt/download?extTranId=QR00000000000&sale_date=…&hash=…&locale=ru",
  "share_link": "https://receipt.kaspi.kz/web?extTranId=QR00000000000&hash=…",
  "sale_date": "2026-01-10 12:00:00.000000",
  "fetched_at": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| status | string |  |
| receipt_link | string \| null | Фискальная форма чека. Секрета не несёт. |
| download_link | string \| null | Прямая ссылка на PDF. ⚠️ Содержит секретный `hash` — не публикуйте и не логируйте. |
| share_link | string \| null | Ссылка для покупателя. ⚠️ Содержит тот же секретный `hash`. |
| sale_date | string \| null | Время продажи в том виде, как его отдал Kaspi (микросекунды значащие). |
| fetched_at | string \| null | Когда чек был получен от Kaspi. |

**Response 202:** Чек запрашивается — повторите запрос через poll_after секунд

Content-Type: `application/json`

```json
{
  "status": "pending",
  "poll_after": 2
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| status | string |  |
| poll_after | integer | Через сколько секунд повторить. |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Счёт не найден
- `409` — Счёт не оплачен либо сессия кассира недоступна
- `429` — Слишком много запросов чеков
- `503` — Kaspi не отдал чек. Отказ запоминается: до истечения `Retry-After` повтор по тому
же счёту получит тот же ответ, а при повторяющихся отказах пауза растёт.


---

### POST /invoices/{invoice}/simulate-status

**Только sandbox** (`is_sandbox=true` у счёта; в рабочем режиме недоступно
ни при каких условиях). Переводит sandbox-счёт из `pending` в
`paid`/`cancelled`/`expired`/`error` и шлёт вебхук `invoice.status_changed`,
либо симулирует транзиентное событие `qr_scanned` (только для QR-счетов:
статус остаётся `pending`, уходит вебхук `invoice.qr_scanned` с
`qr_substate: "scanned"`, повтор → `400 already_scanned`).

При `status=error` счёт получает `error_code: sandbox_simulated_error` и
`error_message` (свой текст — через одноимённый параметр). Опционально можно
задать `kaspi_source_type`/`kaspi_sale_type` (иначе выбираются случайно при `paid`).

Rate-limit: 60/мин на ключ (отдельный бакет, не расходует общий лимит 200/мин).


**Request:**
```json
{
  "status": "paid",
  "kaspi_source_type": "GOLD",
  "kaspi_sale_type": "Remote",
  "error_message": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| status | string | Yes |  |
| kaspi_source_type | string | No |  |
| kaspi_sale_type | string | No |  |
| error_message | string | No | Кастомный текст ошибки (только при `status=error`; по умолчанию «Симулированная ошибка (sandbox).»). |

**Response 200:** Статус симулирован (для `qr_scanned` статус счёта остаётся `pending`)

Content-Type: `application/json`

```json
{
  "message": "Invoice status simulated",
  "invoice": {
    "id": 42,
    "amount": "15000.00",
    "phone_number": "87001234567",
    "description": null,
    "external_order_id": null,
    "status": "processing",
    "client_name": "Иван Иванов",
    "internal_comment": null,
    "buyer_bin": null,
    "is_sandbox": false,
    "is_imported": false,
    "total_refunded": "0.00",
    "is_fully_refunded": false,
    "kaspi_invoice_id": "13234689513",
    "kaspi_qr_link": "https://kaspi.kz/qr/pay?tranId=QR13234689513",
    "error_message": null,
    "error_code": "idempotency_conflict",
    "items": [
      {
        "id": 1,
        "invoice_id": 42,
        "catalog_item_id": null,
        "name": "Coffee Latte",
        "price": "1800.00",
        "count": 2,
        "unit_id": 1,
        "discount": null,
        "barcode": null,
        "ntin": null,
        "gtin": null
      }
    ],
    "subtotal": "",
    "discount_sum": "",
    "discount_percentage": "",
    "paid_at": null,
    "created_at": "2026-01-10T12:00:00+00:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| invoice | object | Полный объект счёта (GET /invoices/{id}). Даты UTC +00:00. |
| invoice.id | integer |  |
| invoice.amount | string |  |
| invoice.phone_number | string |  |
| invoice.description | string \| null |  |
| invoice.external_order_id | string \| null |  |
| invoice.status | string | Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true. |
| invoice.client_name | string \| null |  |
| invoice.internal_comment | string \| null | Внутренняя заметка мерчанта (правится через PATCH /invoices/{id}). |
| invoice.buyer_bin | string \| null | ИИН/БИН покупателя, если был передан при создании. |
| invoice.is_sandbox | boolean |  |
| invoice.is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| invoice.total_refunded | string |  |
| invoice.is_fully_refunded | boolean |  |
| invoice.kaspi_invoice_id | string \| null |  |
| invoice.kaspi_qr_link | string \| null | QR-ссылка Kaspi на этот счёт — нарисуйте из неё QR-код или откройте на телефоне покупателя. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов. |
| invoice.error_message | string \| null |  |
| invoice.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| invoice.items | object[] |  |
| invoice.items.id | integer |  |
| invoice.items.invoice_id | integer |  |
| invoice.items.catalog_item_id | integer \| null | null если товар удалён. |
| invoice.items.name | string |  |
| invoice.items.price | string |  |
| invoice.items.count | integer |  |
| invoice.items.unit_id | integer \| null |  |
| invoice.items.discount | string \| null |  |
| invoice.items.barcode | string \| null | Нацкаталог-поля позиции (если были в корзине). |
| invoice.items.ntin | string \| null |  |
| invoice.items.gtin | string \| null |  |
| invoice.subtotal | string | Только при наличии скидки. |
| invoice.discount_sum | string | Только при наличии скидки. |
| invoice.discount_percentage | string | Только если передан. |
| invoice.paid_at | string \| null |  |
| invoice.created_at | string |  |

**Errors:**
- `400` — invalid_status_transition (счёт не в pending), not_qr_invoice
(`qr_scanned` для не-QR счёта), already_scanned (повторный `qr_scanned`)
или organization_required

- `401` — X-API-Key отсутствует или невалиден
- `403` — not_sandbox — симуляция доступна только для sandbox-счетов
- `404` — Счёт не найден

---

### GET /static-qr

Печатные листы вашей организации (новые сверху). Плоская пагинация `page`/`per_page` (≤100).

**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| per_page | integer | 25 |  |
| page | integer | 1 |  |

**Response 200:** Список

Content-Type: `application/json`

```json
{
  "data": [
    {
      "payment_amount": null,
      "payment_url": null,
      "expires_at": null,
      "expired": false,
      "id": 10,
      "token": "9f1c…",
      "short_code": "K7M9P2Q4",
      "print_url": "https://qr.apipay.kz/9f1c…",
      "manual_url": "https://qr.apipay.kz",
      "qr_image_url": null,
      "amount": "5000.00",
      "description": null,
      "external_order_id": null,
      "single_use": true,
      "status": "active",
      "is_sandbox": false,
      "scan_count": 0,
      "paid": false,
      "paid_at": null,
      "created_at": "2026-07-26T12:00:00+05:00"
    }
  ],
  "current_page": 1,
  "last_page": 1,
  "per_page": 25,
  "total": 3
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.payment_amount | string \| null | Текущая сумма заказа для показа плательщику. |
| data.payment_url | string \| null | Ссылка на оплату (payment link) — отправьте её покупателю в мессенджер или поставьте кнопкой на сайте. Открытие страницы счёт не создаёт (превью мессенджера и повторные открытия безопасны); счёт выпускается, когда покупатель нажмёт «Оплатить». Пока страница открыта, она сама следит за QR: истёкший неотсканированный QR перевыпускается без нажатия, а отсканированный (открытый в Kaspi) прячется с подсказкой «Оплатите в приложении Kaspi» и кнопкой нового QR. Работает, пока ссылка активна: не оплачена, не отключена `DELETE /static-qr/{id}` и не наступил `expires_at`, если он задан. `null`, пока платёжные ссылки не включены: тогда покупателю ведёт только `print_url`, зашитый в QR листа. |
| data.expires_at | string \| null |  |
| data.expired | boolean |  |
| data.id | integer |  |
| data.token | string | Неугадываемый токен — кодируется в QR (в URL печатного листа). |
| data.short_code | string | 8 символов для ручного ввода на qr.apipay.kz. |
| data.print_url | string | Адрес печатного листа — его кодирует QR на листе. Отправить покупателю ссылку на оплату этой сделки — `payment_url`. |
| data.manual_url | string | Куда вводить short_code, если скан не сработал. |
| data.qr_image_url | string \| null | Готовый PNG печатного QR (непротухающий). |
| data.amount | string \| null |  |
| data.description | string \| null |  |
| data.external_order_id | string \| null |  |
| data.single_use | boolean |  |
| data.status | string |  |
| data.is_sandbox | boolean |  |
| data.scan_count | integer | Сколько раз открывали страницу листа (включая повторные открытия и предпросмотры ссылок). Это не число счетов: счёт создаётся только по кнопке «Оплатить». |
| data.paid | boolean | Есть ли оплаченный связанный счёт. Частично возвращённый счёт (partially_refunded) тоже считается оплаченным: деньги по сделке получены, частичный возврат её не открывает заново. |
| data.paid_at | string \| null |  |
| data.created_at | string |  |
| current_page | integer |  |
| last_page | integer |  |
| per_page | integer |  |
| total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден

---

### POST /static-qr

Создаёт печатный лист, привязанный к сделке. Kaspi **не вызывается** — счёт
создаётся в контексте вашей организации (деньги и вебхук приходят вам штатно), когда
покупатель открыл `qr.apipay.kz/{token}` и **нажал «Оплатить»**. Само открытие
страницы (скан камерой, повторное открытие, предпросмотр ссылки в мессенджере) счёт
не выпускает и в Kaspi не обращается. Тело — как у счёта: `amount` **либо**
`cart_items`. Ответ несёт `token` (кодируется в QR), `short_code` (печатается рядом
для ручного ввода), `print_url` (адрес листа, зашитый в QR), `qr_image_url` (готовый
PNG) и `payment_url` — ссылку на оплату этой же сделки, которую можно отправить
покупателю вместо печати.
Модель: **одна сделка** (`single_use=true`) — после оплаты повторное открытие
показывает «Оплачено»; повторные нажатия до оплаты переиспользуют живой Kaspi-QR (не
плодят счета). Если покупатель уже отсканировал QR и не завершил оплату, страница
ждёт её и нового QR не выпускает; новый счёт появится, только если покупатель явно
попросит новый QR (страница предупреждает о риске двойной оплаты). В этом случае
оплатить могут любой из двух QR — реагируйте на `paid` по каждому `invoice.id`.

**Несовместимость ловится здесь, а не при скане.** Лист печатают один раз, а
материализуется он спустя дни, поэтому всё, что сделало бы скан покупателя нерабочим
(нет `cart_items` у мерчанта с каталогом, чужая/удалённая позиция каталога, чужой или
неактивный `kaspi_connection_id`, неоднозначный кассир, непроверенная организация,
забракованное `description`), отбивается **на генерации** — см. `400`/`422`.

⚠️ **Проверка работает на момент генерации.** Если позиция каталога из `cart_items`
позже уйдёт на снятие (`status=deleting`) или будет снята (`status=deleted`), скан такого листа перестанет
создавать счёт до возврата позиции — а переиздать напечатанный лист нельзя. Верните
позицию обычным `POST /catalog`, и лист снова заработает. Перед массовым удалением
каталога проверьте, какие позиции стоят в напечатанных листах.

В **sandbox** не проверяется только верификация организации (её счета в Kaspi не
уходят) — проверки кассира и корзины действуют и в песочнице, в отличие от
`POST /invoices/qr`, который в sandbox кассира не резолвит вовсе. Это сделано
намеренно: лист с чужим или неоднозначным адресатом сломался бы при скане, и
песочница нужна ровно для того, чтобы увидеть это до печати.

⚠️ **Оплата с листа по номеру телефона требует целой суммы.** QR-ветка тиыны
принимает, телефонная — нет: лист с дробной `amount` или дробным итогом `cart_items`
покупателю по номеру телефона выставить не удастся (`422 amount_must_be_whole_tenge`),
а переиздать напечатанный лист нельзя. Для листов, где нужна оплата по номеру,
задавайте целую сумму.

⛔ **Скидка на листе** — по тому же правилу, что у `POST /invoices/qr`: процент только
целый, скидка строки округляется вверх до целого тенге; позиция с ценой в тиынах и
позиция, которую скидка обнуляет, — `422 qr_discount_unsupported` при выпуске. Скидка
применяется только к `cart_items`: на листе с `amount` она ни на что не влияет.
**Лист с корзиной и скидкой оплачивается только по QR**: оплаты по номеру телефона на
его странице нет (у телефонного счёта Kaspi скидка считается иначе, и сумма разошлась
бы с листом).


**Request:**
```json
{
  "amount": 5000,
  "cart_items": [
    {
      "catalog_item_id": 42,
      "count": 1,
      "price": 5000
    }
  ],
  "discount_percentage": null,
  "description": "Заказ №1024",
  "external_order_id": null,
  "buyer_bin": "",
  "single_use": true,
  "kaspi_connection_id": null,
  "expires_at": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| amount | number | No | Сумма сделки. Обязателен, если нет cart_items. |
| cart_items | array | No | Позиции из синхронизированного каталога (для мерчантов с каталогом). |
| discount_percentage | integer | No | Целый процент 1–99 (дробный → 422). Скидка строки = ceil(цена × количество × процент / 100) в целых тенге. Применяется только к cart_items. Со скидкой позиция с ценой в тиынах или обнулённая скидкой позиция → 422 qr_discount_unsupported. Лист с корзиной и скидкой оплачивается только по QR. |
| description | string | No | Наименование позиции в чеке Kaspi — до 100 символов (предел Kaspi).

⚠️ Лист должен принимать оплату **по номеру телефона** (запасной путь есть на
странице каждого листа), поэтому описание обязано укладываться ещё и в лимит
описания счёта — 60 символов. Слишком длинное описание отбивается при выпуске
листа с `error_code: description_too_long`, а не ломает телефонный путь молча.
⛔ Изменить описание у выпущенного листа нельзя — только `DELETE` и новый лист.
 |
| external_order_id | string | No | Ссылка на сделку в вашей системе (придёт в вебхуке). |
| buyer_bin | any | No | Не принимается: покупатель при печати листа неизвестен (422 errors.buyer_bin). |
| single_use | boolean | No | true — одна сделка (замок «Оплачено» после оплаты). |
| kaspi_connection_id | integer | No | Кассир/точка (по умолчанию primary). |
| expires_at | string | No | Срок годности сделки (в будущем). |

**Response 201:** Лист создан

Content-Type: `application/json`

```json
{
  "payment_amount": null,
  "payment_url": null,
  "expires_at": null,
  "expired": false,
  "id": 10,
  "token": "9f1c…",
  "short_code": "K7M9P2Q4",
  "print_url": "https://qr.apipay.kz/9f1c…",
  "manual_url": "https://qr.apipay.kz",
  "qr_image_url": null,
  "amount": "5000.00",
  "description": null,
  "external_order_id": null,
  "single_use": true,
  "status": "active",
  "is_sandbox": false,
  "scan_count": 0,
  "paid": false,
  "paid_at": null,
  "created_at": "2026-07-26T12:00:00+05:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| payment_amount | string \| null | Текущая сумма заказа для показа плательщику. |
| payment_url | string \| null | Ссылка на оплату (payment link) — отправьте её покупателю в мессенджер или поставьте кнопкой на сайте. Открытие страницы счёт не создаёт (превью мессенджера и повторные открытия безопасны); счёт выпускается, когда покупатель нажмёт «Оплатить». Пока страница открыта, она сама следит за QR: истёкший неотсканированный QR перевыпускается без нажатия, а отсканированный (открытый в Kaspi) прячется с подсказкой «Оплатите в приложении Kaspi» и кнопкой нового QR. Работает, пока ссылка активна: не оплачена, не отключена `DELETE /static-qr/{id}` и не наступил `expires_at`, если он задан. `null`, пока платёжные ссылки не включены: тогда покупателю ведёт только `print_url`, зашитый в QR листа. |
| expires_at | string \| null |  |
| expired | boolean |  |
| id | integer |  |
| token | string | Неугадываемый токен — кодируется в QR (в URL печатного листа). |
| short_code | string | 8 символов для ручного ввода на qr.apipay.kz. |
| print_url | string | Адрес печатного листа — его кодирует QR на листе. Отправить покупателю ссылку на оплату этой сделки — `payment_url`. |
| manual_url | string | Куда вводить short_code, если скан не сработал. |
| qr_image_url | string \| null | Готовый PNG печатного QR (непротухающий). |
| amount | string \| null |  |
| description | string \| null |  |
| external_order_id | string \| null |  |
| single_use | boolean |  |
| status | string |  |
| is_sandbox | boolean |  |
| scan_count | integer | Сколько раз открывали страницу листа (включая повторные открытия и предпросмотры ссылок). Это не число счетов: счёт создаётся только по кнопке «Оплатить». |
| paid | boolean | Есть ли оплаченный связанный счёт. Частично возвращённый счёт (partially_refunded) тоже считается оплаченным: деньги по сделке получены, частичный возврат её не открывает заново. |
| paid_at | string \| null |  |
| created_at | string |  |

**Errors:**
- `400` — `Organization not found or not verified` — боевая (не sandbox) организация ещё не
верифицирована, счёт по такому листу создать было бы нельзя. Форма ответа — как у
`POST /invoices/qr`. ⚠️ В этом теле `error` несёт **человеческую фразу, а не slug**,
и `error_code` отсутствует — не разбирайте `error` машинно (см. пример).

- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка на ApiPay не активна.
- `422` — Ошибки формы тела (`{message: "Validation failed", errors: {…}}`) плюс проверки
«этот лист сможет материализоваться», выполняемые до печати:

* мерчант **с** каталогом без `cart_items` либо **без** каталога с `cart_items` —
  тело несёт `message` и `error_code` (`catalog_requires_cart_items` /
  `catalog_not_supported`), но **без** `errors`, как на `POST /invoices/qr`;
* `cart_items.*.catalog_item_id` не принадлежит вашей организации, имеет
  `status=deleted`, стоит в очереди на снятие (`status=deleting`), имеет брошенное
  создание (`status=failed` при `operation=create`) или без цены —
  `errors["cart_items.0.catalog_item_id"]` с тем же текстом, что вернуло бы
  создание счёта (`Catalog item does not belong to your organization.` /
  `… has been deleted.` / `Catalog item is being deleted…` /
  `Catalog item was not created in Kaspi…` / `… has no price set.`). Позицию из
  очереди снятия можно вернуть обычным `POST /catalog`, после чего она снова
  принимается в корзину; брошенное создание возобновляется только
  `PATCH /catalog/{id}`;
* `kaspi_connection_id` чужой или неактивный — `errors.kaspi_connection_id`
  (`422`, а не `404`: код ответа не раскрывает существование чужого кассира);
* `connection_ambiguous` — у организации больше одного активного кассира и нет
  primary; передайте `kaspi_connection_id` явно;
* `description` забракован контент-скринингом (он становится именем позиции в
  чеке Kaspi) — `errors.description` + `error_code`.

⚠️ **Несуществующий** `catalog_item_id` / `kaspi_connection_id` отдаёт **ровно то
же тело**, что чужой: ответ намеренно не различает «нет такого id» и «id чужой»,
иначе по нему перебирались бы чужие сущности.

Коды ветки: `catalog_requires_cart_items` и `catalog_not_supported` —
те же, что на `POST /invoices/qr` (добавлены 11.08.2026 аддитивно,
`message` не менялся).


---

### GET /static-qr/{id}

Карточка листа. Чужой id → `404` (non-enumeration).

**Response 200:** Карточка листа

Content-Type: `application/json`

```json
{
  "payment_amount": null,
  "payment_url": null,
  "expires_at": null,
  "expired": false,
  "id": 10,
  "token": "9f1c…",
  "short_code": "K7M9P2Q4",
  "print_url": "https://qr.apipay.kz/9f1c…",
  "manual_url": "https://qr.apipay.kz",
  "qr_image_url": null,
  "amount": "5000.00",
  "description": null,
  "external_order_id": null,
  "single_use": true,
  "status": "active",
  "is_sandbox": false,
  "scan_count": 0,
  "paid": false,
  "paid_at": null,
  "created_at": "2026-07-26T12:00:00+05:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| payment_amount | string \| null | Текущая сумма заказа для показа плательщику. |
| payment_url | string \| null | Ссылка на оплату (payment link) — отправьте её покупателю в мессенджер или поставьте кнопкой на сайте. Открытие страницы счёт не создаёт (превью мессенджера и повторные открытия безопасны); счёт выпускается, когда покупатель нажмёт «Оплатить». Пока страница открыта, она сама следит за QR: истёкший неотсканированный QR перевыпускается без нажатия, а отсканированный (открытый в Kaspi) прячется с подсказкой «Оплатите в приложении Kaspi» и кнопкой нового QR. Работает, пока ссылка активна: не оплачена, не отключена `DELETE /static-qr/{id}` и не наступил `expires_at`, если он задан. `null`, пока платёжные ссылки не включены: тогда покупателю ведёт только `print_url`, зашитый в QR листа. |
| expires_at | string \| null |  |
| expired | boolean |  |
| id | integer |  |
| token | string | Неугадываемый токен — кодируется в QR (в URL печатного листа). |
| short_code | string | 8 символов для ручного ввода на qr.apipay.kz. |
| print_url | string | Адрес печатного листа — его кодирует QR на листе. Отправить покупателю ссылку на оплату этой сделки — `payment_url`. |
| manual_url | string | Куда вводить short_code, если скан не сработал. |
| qr_image_url | string \| null | Готовый PNG печатного QR (непротухающий). |
| amount | string \| null |  |
| description | string \| null |  |
| external_order_id | string \| null |  |
| single_use | boolean |  |
| status | string |  |
| is_sandbox | boolean |  |
| scan_count | integer | Сколько раз открывали страницу листа (включая повторные открытия и предпросмотры ссылок). Это не число счетов: счёт создаётся только по кнопке «Оплатить». |
| paid | boolean | Есть ли оплаченный связанный счёт. Частично возвращённый счёт (partially_refunded) тоже считается оплаченным: деньги по сделке получены, частичный возврат её не открывает заново. |
| paid_at | string \| null |  |
| created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Лист не найден

---

### DELETE /static-qr/{id}

Помечает лист `disabled` — последующие сканы показывают «неактивно», новые счета не
создаются. Уже созданные счета не трогаются. Чужой id → `404`.


**Response 200:** Лист отключён (возвращается карточка со status=disabled)

Content-Type: `application/json`

```json
{
  "payment_amount": null,
  "payment_url": null,
  "expires_at": null,
  "expired": false,
  "id": 10,
  "token": "9f1c…",
  "short_code": "K7M9P2Q4",
  "print_url": "https://qr.apipay.kz/9f1c…",
  "manual_url": "https://qr.apipay.kz",
  "qr_image_url": null,
  "amount": "5000.00",
  "description": null,
  "external_order_id": null,
  "single_use": true,
  "status": "active",
  "is_sandbox": false,
  "scan_count": 0,
  "paid": false,
  "paid_at": null,
  "created_at": "2026-07-26T12:00:00+05:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| payment_amount | string \| null | Текущая сумма заказа для показа плательщику. |
| payment_url | string \| null | Ссылка на оплату (payment link) — отправьте её покупателю в мессенджер или поставьте кнопкой на сайте. Открытие страницы счёт не создаёт (превью мессенджера и повторные открытия безопасны); счёт выпускается, когда покупатель нажмёт «Оплатить». Пока страница открыта, она сама следит за QR: истёкший неотсканированный QR перевыпускается без нажатия, а отсканированный (открытый в Kaspi) прячется с подсказкой «Оплатите в приложении Kaspi» и кнопкой нового QR. Работает, пока ссылка активна: не оплачена, не отключена `DELETE /static-qr/{id}` и не наступил `expires_at`, если он задан. `null`, пока платёжные ссылки не включены: тогда покупателю ведёт только `print_url`, зашитый в QR листа. |
| expires_at | string \| null |  |
| expired | boolean |  |
| id | integer |  |
| token | string | Неугадываемый токен — кодируется в QR (в URL печатного листа). |
| short_code | string | 8 символов для ручного ввода на qr.apipay.kz. |
| print_url | string | Адрес печатного листа — его кодирует QR на листе. Отправить покупателю ссылку на оплату этой сделки — `payment_url`. |
| manual_url | string | Куда вводить short_code, если скан не сработал. |
| qr_image_url | string \| null | Готовый PNG печатного QR (непротухающий). |
| amount | string \| null |  |
| description | string \| null |  |
| external_order_id | string \| null |  |
| single_use | boolean |  |
| status | string |  |
| is_sandbox | boolean |  |
| scan_count | integer | Сколько раз открывали страницу листа (включая повторные открытия и предпросмотры ссылок). Это не число счетов: счёт создаётся только по кнопке «Оплатить». |
| paid | boolean | Есть ли оплаченный связанный счёт. Частично возвращённый счёт (partially_refunded) тоже считается оплаченным: деньги по сделке получены, частичный возврат её не открывает заново. |
| paid_at | string \| null |  |
| created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Лист не найден

---

### POST /receipts/preview

Синхронное превью чека (pre-details) перед выбиванием — для UI. Возвращает
список строк `{Title, Subtitle, isBoldText}` (сумма, способ оплаты).

Для оплат, НЕ прошедших через Kaspi QR: `payment_type=3` (наличные),
`payment_type=5` (POS другого банка).
Sandbox отдаёт детерминированное превью без Kaspi.


**Request:**
```json
{
  "payment_type": 3,
  "total_price": 10,
  "kaspi_connection_id": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| payment_type | integer | Yes | 3 — наличные, 5 — POS другого банка. |
| total_price | number | Yes | Сумма чека. |
| kaspi_connection_id | integer | No | Конкретный кассир; по умолчанию — primary/единственный активный. |

**Response 200:** Превью

Content-Type: `application/json`

```json
{
  "data": [
    {
      "Title": "Способ оплаты",
      "Subtitle": "Наличные",
      "isBoldText": false
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.Title | string |  |
| data.Subtitle | string |  |
| data.isBoldText | boolean |  |

**Errors:**
- `400` — Не определена организация (organization_required)
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка на ApiPay не активна (грейса нет, блок сразу
после `expires_at`; тело несёт `expires_at`).

- `409` — Нет активного кассира (kaspi_session_not_configured)
- `422` — Неоднозначность кассира (connection_ambiguous) или невалидный payment_type/total_price
- `503` — Kaspi недоступен (receipt_preview_unavailable)

---

### GET /receipts

Пагинированный список чеков организации, свежие сверху (`created_at DESC`).
Пагинация — **плоская** `{current_page, data, total}` (не meta-обёртка).
Элемент списка — та же форма, что у `GET /receipts/{id}`.

Выборка скоупится режимом организации: боевая организация видит только боевые
чеки, тестовая — только тестовые.

**Чтение истории не гейтится ничем**: список уже выбитых чеков остаётся доступен
всегда.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| status | string | — | Фильтр по статусу. |
| payment_type | integer | — | 3 — наличные, 5 — POS другого банка. |
| invoice_id | integer | — | Чеки, привязанные к конкретному счёту. |
| from | string | — | Нижняя граница `created_at` (дата или дата-время; минуты и секунды опциональны —
`2026-07-13`, `2026-07-13 10`, `2026-07-13 10:30`). Голая дата и дата-время без
смещения трактуются в **Asia/Almaty** (+05:00) — календарный день мерчанта;
явное смещение (`2026-07-13T10:00:00+03:00`, `...Z`) берётся как есть.
 |
| to | string | — | Верхняя граница `created_at`; должна быть >= `from`, иначе `422`. Голая дата
(`2026-07-13`) включает весь день целиком **по Asia/Almaty**; дата-время (минуты
и секунды опциональны) означает ровно этот момент — `10` это `10:00:00`, а не
конец часа. Без смещения трактуется в Asia/Almaty, с явным — берётся как есть.
 |
| per_page | integer | 20 |  |
| page | integer | 1 |  |

**Response 200:** Список чеков

Content-Type: `application/json`

```json
{
  "current_page": 1,
  "data": [
    {
      "id": 42,
      "status": "pending",
      "payment_type": 3,
      "invoice_id": null,
      "client_operation_id": null,
      "total_price": "1500.00",
      "received_amt": "2000.00",
      "fpd": null,
      "operation_id": null,
      "operation_time": null,
      "shift_number": null,
      "link": null,
      "error_code": null,
      "error_message": null,
      "created_at": null
    }
  ],
  "total": 12
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.status | string |  |
| data.payment_type | integer |  |
| data.invoice_id | integer \| null | Счёт, к которому привязан чек (если есть). |
| data.client_operation_id | string \| null |  |
| data.total_price | string \| null |  |
| data.received_amt | string \| null |  |
| data.fpd | string \| null |  |
| data.operation_id | string \| null |  |
| data.operation_time | string \| null |  |
| data.shift_number | integer \| null |  |
| data.link | string \| null | Ссылка на чек на receipt.kaspi.kz. |
| data.error_code | string \| null |  |
| data.error_message | string \| null |  |
| data.created_at | string \| null |  |
| total | integer |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### POST /receipts

Асинхронно выбивает фискальный чек в Kaspi OFD для оплаты наличными
(`payment_type=3`) или через POS другого банка (`payment_type=5`). Позиции —
из синхронизированного каталога по `catalog_item_id` (как `/invoices` with-cart);
в бою позиция обязана быть синхронизирована с Kaspi (`in_kaspi_catalog`), иначе
`item_not_fiscal` (в песочнице `in_kaspi_catalog` всегда `false`, и чек по ней выбивается). Позиция без НТИН и без штрихкода (например, услуга) выбивается
без маркировки Нацкаталога; позиция со штрихкодом, но без НТИН (`ntin_missing`) —
`item_not_fiscal`; позиция со ставкой НДС — `receipt_vat_not_supported`.
Произвольная сумма пробивается универсальной позицией каталога с `price` в строке.

Создаёт чек в статусе `pending` и ставит задачу выбивания. Клиент узнаёт итог
через `GET /receipts/{id}` или вебхук `receipt.issued`/`receipt.failed`.

**Идемпотентность:** `client_operation_id` уникален на организацию — повтор с тем
же ключом не выбивает второй чек (`409 duplicate_client_operation_id`); повтор
после `failed` разрешён.

**Песочница.** Чек зеркалит бой, те же отказы: позиция каталога даёт `issued` с
реальными суммами, `ntin_missing` — `item_not_fiscal`. Kaspi не вызывается,
фискальный документ не пишется. Поле `simulate` (только sandbox,
иначе `403 not_sandbox`) форсирует исход — так воспроизводятся `shift_closed`,
`item_not_fiscal`, `receipt_kaspi_error`. Вебхуки `receipt.issued`/`receipt.failed`
уходят при любом терминальном статусе чека.


**Request:**
```json
{
  "payment_type": 3,
  "client_operation_id": "",
  "kaspi_connection_id": null,
  "received_amt": null,
  "cart_items": [
    {
      "catalog_item_id": 0,
      "quantity": 1,
      "price": null
    }
  ],
  "simulate": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| payment_type | integer | Yes |  |
| client_operation_id | string | Yes | Ключ идемпотентности (уникален на организацию). |
| kaspi_connection_id | integer | No |  |
| received_amt | number | No | Наличные: полученная сумма (>= итога, для сдачи). POS другого банка игнорируется (= итогу). |
| cart_items | array | Yes |  |
| simulate | object | No | **Только песочница** (боевая организация → `403 not_sandbox`). Форсирует
исход чека, чтобы интегратор обкатал обработку ошибок.
 |

**Response 202:** Чек принят в обработку

Content-Type: `application/json`

```json
{
  "id": 0,
  "status": "pending",
  "client_operation_id": ""
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string |  |
| client_operation_id | string |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — `not_sandbox` — simulate прислан вне песочницы; `tariff_inactive` — подписка
на ApiPay не активна.

- `409` — Дубль `client_operation_id` (`duplicate_client_operation_id`); нет кассира
(`kaspi_session_not_configured`); `kaspi_session_expired` — сессия кассира мертва,
чек не выбит (нужна переавторизация кассира).

У дубля в теле приходят `receipt_id` и `status` уже созданного чека — по ним
читайте его через `GET /receipts/{id}`, а не выбивайте заново.

- `422` — Ошибка валидации / connection_ambiguous
- `503` — `receipt_unavailable` — выбивание чеков временно приостановлено на нашей стороне
(технические работы). Чек НЕ создан и `client_operation_id` НЕ занят — повторите
тот же запрос позже; `Retry-After` в заголовке.


---

### GET /receipts/{id}

Возвращает текущий статус чека и его реквизиты (`fpd`, `operation_id`, `link` —
публичная страница чека, открывается в обычном браузере, — `shift_number`).
`status`: `pending` | `issued` | `failed`. Чужой `id` → `404`.


**Response 200:** Статус чека

Content-Type: `application/json`

```json
{
  "id": 0,
  "status": "pending",
  "payment_type": 3,
  "invoice_id": null,
  "client_operation_id": null,
  "total_price": null,
  "received_amt": null,
  "fpd": null,
  "operation_id": null,
  "operation_time": null,
  "shift_number": null,
  "link": "https://receipt.kaspi.kz/web/fiscal?i=000000000000&f=000000000000&s=10&t=2026-07-12%2015%3A25%3A43",
  "error_code": null,
  "error_message": null,
  "created_at": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string |  |
| payment_type | integer |  |
| invoice_id | integer \| null |  |
| client_operation_id | string \| null |  |
| total_price | string \| null |  |
| received_amt | string \| null |  |
| fpd | string \| null |  |
| operation_id | string \| null |  |
| operation_time | string \| null |  |
| shift_number | integer \| null |  |
| link | string \| null | Публичная страница чека — `receipt.kaspi.kz/web/fiscal?i={ФПД}&f={РНМ кассы}&s={сумма}&t={время операции}`, открывается в любом браузере. В редком ответе Kaspi без РНМ кассы здесь остаётся внутренняя страница приложения Kaspi Pay (`/preview/cashier`), которая вне приложения чек не отрисовывает. У чеков песочницы — тестовая страница чека ApiPay (суммы и позиции настоящие, реквизиты — заглушки); в бою ссылка всегда на `receipt.kaspi.kz`. |
| error_code | string \| null |  |
| error_message | string \| null |  |
| created_at | string \| null |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Чек не найден (receipt_not_found)

---

### POST /qr-refunds

Создаёт сессию возврата и отдаёт возвратный QR (`qr_token_url`/`qr_image_url`),
который мерчант показывает клиенту прямо у кассы. Клиент сканирует → сессия переходит
в `customer_identified` (вебхук `qr_refund.identified`), после чего доступен список
его возвратных операций (`GET .../operations`).

⚠️ **Окно скана — не более 90 секунд**, и это изменение поведения. Раньше `expires_at`
брался из `ExpireDate` Kaspi (около пяти минут), тогда как реальное окно составляло
~90 секунд: интегратор видел живой QR у уже мёртвой сессии. Теперь `expires_at` —
минимум из подсказки провайдера и нашего потолка, а `scan_wait_timeout_seconds`
сообщает ДЕЙСТВУЮЩЕЕ окно. Просроченные `qr_token_url`/`qr_image_url` не выдаются ни
в этом ответе, ни в `GET /qr-refunds/{id}`.

⛔ Отправив запрос, повторять его нельзя: попытка одноразовая. `502
qr_refund_activation_failed` означает «начать не удалось, нужен новый старт», а не
«попробуйте ещё раз тем же запросом».

**Deprecated** в пользу `POST /qr-refunds/links`: покупателя обычно нет рядом с
кассой, и отсканировать QR с чужого экрана ему нечем. Эндпоинт остаётся рабочим и
удаляться не планируется.


**Request:**
```json
{
  "kaspi_connection_id": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| kaspi_connection_id | integer | No | Кассир; по умолчанию primary |

**Response 201:** Сессия создана

Content-Type: `application/json`

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "execution_started_at": null,
  "execution_uncertain_at": null,
  "error_code": null,
  "error_message": null,
  "client_name": "Иван И.",
  "qr_token_url": null,
  "qr_image_url": null,
  "link_expires_at": null,
  "invoice_id": null,
  "refund_amount": "500.00",
  "scan_started_at": null,
  "provider_requested_at": null,
  "expires_at": null,
  "identified_at": null,
  "completed_at": null,
  "refunded_amount": "500.00",
  "receipt_url": null,
  "poll_interval_seconds": null,
  "scan_wait_timeout_seconds": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string | ⚠️ У ссылки статус может вернуться из `awaiting_scan` в `awaiting_customer`: покупатель
не отсканировал код вовремя (это подтвердил Kaspi), и ссылка снова ждёт нажатия (не
более трёх окон на ссылку, `error_code` = `qr_return_scan_timeout`). Это не ошибка и
не терминал.

`failed` — терминал: подтверждение не удалось начать ЛИБО не состоялось
автопроведение ссылки под счёт. Во втором случае `failed` приходит уже ПОСЛЕ
`customer_identified`, и что делать дальше, говорит `error_code` (например,
`refund_insufficient_funds`, `qr_refund_actor_no_longer_authorized`,
`qr_refund_operation_not_found`). Ветвитесь по коду, а не по статусу.
 |
| execution_started_at | string \| null | Когда была взята денежная претензия. Заполнено с `executing` и далее. |
| execution_uncertain_at | string \| null | Когда исход возврата был признан недоказанным. Заполнено только у
`execution_uncertain`.
 |
| error_code | object \| null | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| error_message | string \| null |  |
| client_name | string \| null |  |
| qr_token_url | string \| null | Возвратный QR немедленного старта. ⛔ У сессии, выпущенной как ссылка покупателю,
здесь ВСЕГДА `null`: её цель отдаётся один раз на публичной странице. После
дедлайна и на терминальном статусе поле обнуляется и у немедленного старта.
 |
| qr_image_url | string \| null | Та же цель картинкой; те же правила обнуления |
| link_expires_at | string \| null | Срок НЕАКТИВИРОВАННОЙ ссылки (24 часа с выпуска). Не путать с `expires_at`: тот
появляется только после нажатия покупателя и означает окно скана. Не продлевается
ничем — ни открытием страницы, ни перезагрузкой, ни неудачной попыткой, ни
пропущенным окном. Новое окно после пропущенного открывается только до этого срока.
 |
| invoice_id | integer \| null | Счёт, под который выпущена ссылка (`invoices.id`). `null` — ссылка без привязки. |
| refund_amount | string \| null | Запрошенная сумма возврата по ссылке под счёт, два знака; для return_items — подтверждённая сумма выбранных позиций. `null` — весь остаток счёта
(или ссылка без привязки). Проведённая сумма — `refunded_amount`.
 |
| scan_started_at | string \| null | Начало окна скана. Единственный авторитет дедлайна. |
| provider_requested_at | string \| null | Момент выхода запроса в сеть. У боевой сессии равен `scan_started_at`; у песочной
всегда `null` — сеть там не звалась.
 |
| expires_at | string \| null | Конец текущего окна скана. Не более 90 секунд от `scan_started_at`. У ссылки, вернувшейся в `awaiting_customer` после пропущенного окна, снова `null` (как и `scan_started_at`, `provider_requested_at`) |
| identified_at | string \| null |  |
| completed_at | string \| null |  |
| refunded_amount | string \| null |  |
| receipt_url | string \| null |  |
| poll_interval_seconds | integer \| null | Действующий интервал опроса |
| scan_wait_timeout_seconds | integer \| null | ДЕЙСТВУЮЩЕЕ окно скана в секундах — уже прижатое потолком, а не подсказка
провайдера. ⚠️ Не более 90; раньше здесь могло оказаться около пяти минут.
 |

**Response 202:** qr_refund_activation_in_progress — попытка потрачена, исход ещё не записан; повторять нельзя, состояние читать через GET /qr-refunds/{id}

Без тела ответа.

**Errors:**
- `400` — organization_required / organization_not_verified / kaspi_session_not_configured / sandbox_limit
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `409` — qr_return_scan_timeout — окно скана закрылось до выдачи QR
- `422` — connection_ambiguous / невалидный kaspi_connection_id
- `429` — Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).

⚠️ Поле `message` намеренно осталось прежним — `"Too Many Attempts."`: интеграции,
разбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.

⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит
конкретной ручки (к запросу применяется несколько лимитеров сразу).

- `502` — qr_refund_activation_failed — провайдер не подтвердил старт; попытка потрачена
- `503` — kaspi_session_invalid — сессия кассира недоступна; qr_refund_temporarily_unavailable — операция временно недоступна (ни одного вызова провайдера не сделано)

---

### POST /qr-refunds/links

Создаёт одноразовую ссылку, которую мерчант отправляет покупателю. **Kaspi при этом
не вызывается и таймер не идёт.** Покупатель открывает ссылку и нажимает кнопку —
только тогда создаётся возвратный QR и начинается окно скана (не более 90 секунд).

`customer_url` возвращается **ровно один раз**: в базе хранится только необратимый
хеш, и повторно получить адрес нельзя ни через `GET /qr-refunds/{id}`, ни как-либо
ещё. Потеряли — сначала явно отзовите старую, если сервер допускает отзыв, затем выпускайте новую. При неизвестном денежном результате повтор запрещён.

Срок неактивированной ссылки — `link_expires_at` (24 часа) и не продлевается ничем.

**Пропущенное окно.** Если покупатель не отсканировал код, пока шло окно, и Kaspi это
подтвердил, ссылка возвращается в `awaiting_customer` (статус сессии идёт
`awaiting_scan → awaiting_customer`, `error_code` = `qr_return_scan_timeout`, вебхука нет),
и покупатель может показать новый код той же ссылкой. Окон у одной ссылки **не более
трёх**, каждое открывается только нажатием покупателя и только в пределах
`link_expires_at`; каждое окно — отдельный возвратный QR у Kaspi. Если отсутствие скана
подтвердить не удалось (например, проверка статуса не прошла) или Kaspi больше не знает
операцию (`qr_return_not_found`), окно закрывается без повтора. `qr_refund.expired`
приходит, когда ссылка исчерпана: окно закрыто без повтора, пропущено последнее окно,
истёк срок ссылки или ссылка отозвана. Пока Kaspi временно недоступен, нажатие
покупателя окна не открывает и ссылку не тратит.

Дальнейшее состояние читается через `GET /qr-refunds/{id}`. У ссылки БЕЗ `invoice_id`
денежное выполнение — через существующий `POST /qr-refunds/{id}/execute`: сама она денег
не двигает. Ссылка под счёт проводит возврат сама (см. «Автопроведение»).

**Ссылка под счёт.** С `invoice_id` ссылка привязывается к оплаченному счёту этой
организации: страница покупателя показывает продавца, сумму, дату и состав покупки, а
в ответе и в `GET /qr-refunds/{id}` приходят `invoice_id` и `refund_amount`. Без `amount` и `return_items` к возврату — весь остаток счёта; с `amount` — частичный возврат плоской суммой; с `return_items` — выбранные товары с учётом скидки. Сумма и позиции взаимоисключающие.
Остаток мы проверяем при выпуске по своим данным, а окончательно доступную к возврату
сумму определяет Kaspi в момент возврата. Пока по счёту есть незавершённый обычный или QR-возврат,
второй возврат любым способом создать нельзя (`409 qr_refund_link_invoice_busy`). Без `invoice_id`
поведение прежнее.

**Автопроведение.** По ссылке под счёт возврат проводится САМ, сразу после того как
покупатель подтвердил себя: от имени того, кто выпустил ссылку, по операции именно этого
счёта. Итог приходит вебхуком — `qr_refund.completed`, `qr_refund.failed` с настоящей
причиной в `error_code` (в том числе ПОСЛЕ `qr_refund.identified`) или
`qr_refund.execution_uncertain`. Ручной `POST /qr-refunds/{id}/execute` по такой сессии
остаётся доступным и второго возврата не создаёт: денежный вызов по сессии один, кто бы
его ни начал. Если Kaspi отказал автопроведению, сессия закрывается (`failed`) — повтор
новой ссылкой. Если автопроведение не успело в окно подтверждения покупателя (временная
недоступность), приходит `qr_refund.expired`. Если вы успели вызвать `execute` сами,
исход определяет ваш вызов.


**Request:**
```json
{
  "kaspi_connection_id": null,
  "invoice_id": null,
  "amount": "500.00",
  "return_items": [
    {
      "catalog_item_id": 1,
      "count": 1,
      "amount": 0.01
    }
  ],
  "reason": null,
  "source_refund_id": 1
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| kaspi_connection_id | integer | No | Кассир; по умолчанию primary. Фиксируется при выпуске и не пересматривается |
| invoice_id | integer | No | `id` оплаченного счёта ЭТОЙ организации (наш `invoices.id`, не номер Kaspi).
Обязателен, если передан `amount`, `return_items` или `source_refund_id`. Чужой, несуществующий, неоплаченный,
уже возвращённый целиком и песочный счёт для боевой организации (и наоборот)
отвечают одинаково — `422 invoice_not_refundable`.
 |
| amount | string | No | Сумма частичного возврата, не больше двух знаков после точки. Не передана —
возвращается весь остаток счёта, если не переданы позиции. Взаимоисключим с `return_items` и `source_refund_id`. Больше остатка — `422
refund_amount_exceeds_available`. ⚠️ У мерчанта с каталогом частичная сумма
отклоняется (`422 partial_refund_requires_return_items`), если остаток к
возврату есть больше чем у одной позиции счёта. Окончательно доступную к
возврату сумму определяет Kaspi.
 |
| return_items | array | No | Только со счётом. Взаимоисключим с amount и source_refund_id. На позицию ровно одно из count/amount. Состав и сумма фиксируются при выпуске; неоднозначное сопоставление с покупкой запрещает денежный вызов. |
| reason | string | No | Причина возврата под счёт. |
| source_refund_id | integer | No | Отказанный возврат этого счёта с error_code=refund_rejected_by_kaspi или refund_requires_buyer_confirmation. Сервер восстанавливает сумму, товары и причину из него, игнорируя переданную reason. amount/return_items одновременно запрещены. Изменившийся план требует нового подтверждения мерчанта. |

**Response 201:** Ссылка выпущена

Content-Type: `application/json`

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "customer_url": "https://qr.apipay.kz/refund/0PxK5tG2mQ8vY1nB4wR7sL3dF6hJ9cA0eU2iO5pT8xE",
  "link_expires_at": "2026-01-01T12:00:00+05:00",
  "invoice_id": null,
  "refund_amount": "500.00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string |  |
| customer_url | string | Одноразовая ссылка для покупателя. ⛔ Предъявительская: кто её открыл, тот может
подтвердить возврат. Не публиковать, не класть в аналитику и в отчёты об ошибках.
 |
| link_expires_at | string | Срок неактивированной ссылки |
| invoice_id | integer \| null | Счёт, под который выпущена ссылка; `null` — без привязки |
| refund_amount | string \| null | Запрошенная сумма (для return_items — сумма позиций); `null` — весь остаток счёта |

**Errors:**
- `400` — organization_required / organization_not_verified / kaspi_session_not_configured
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `409` — qr_refund_link_invoice_busy — по этому счёту уже есть незавершённый обычный или QR-возврат, включая неизвестный исход; дождитесь результата. Отзыв допустим только для ещё не активированной ссылки
- `422` — connection_ambiguous / невалидный kaspi_connection_id, invoice_id, amount, return_items или source_refund_id / invoice_not_refundable / refund_amount_exceeds_available / partial_refund_requires_return_items
- `429` — qr_refund_link_limit_reached — слишком много неиспользованных ссылок (бизнес-лимит, повтор не поможет); request_rate_limited — поминутный лимит `/qr-refunds*`, форма ответа — как у `RateLimitWindow` (Retry-After, X-RateLimit-*)
- `503` — `kaspi_session_invalid` — сессия кассира недоступна; `qr_refund_temporarily_unavailable` —
выпуск временно невозможен — например, пока не завершён другой выпуск ссылки той же
организации; `qr_refund_execution_disabled` — ссылку под счёт выпустить сейчас нельзя:
выполнение возврата временно недоступно.
Ни одного вызова провайдера не сделано, повторять можно.


---

### DELETE /qr-refunds/links/{id}

Гасит ссылку, которую покупатель ещё не открыл. Повторный отзыв той же ссылки
идемпотентен и возвращает тот же снимок.

Во время окна скана (`activating`, `awaiting_scan`) отзыв тоже отвечает `200`, но статус
в снимке не меняется: текущее окно доживает своё (если покупатель успеет отсканировать,
подтверждение и возврат пройдут как обычно), а новых окон по ссылке больше не будет —
пропущенное окно закроет её `expired`.

⛔ После подтверждения покупателя отозвать ссылку нельзя:
`409 qr_refund_link_not_revocable` (если она не была отозвана раньше — тогда повтор
отвечает `200`).

⚠️ Роут доступен и при истёкшем тарифе: запереть дверь изнутри мерчант обязан мочь
всегда. Выпуск новой ссылки при этом остаётся платной операцией.


**Response 200:** Ссылка отозвана: статус `expired`, `error_code` = `qr_refund_link_revoked`; во время окна скана — прежний статус окна, новых окон не будет

Content-Type: `application/json`

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "execution_started_at": null,
  "execution_uncertain_at": null,
  "error_code": null,
  "error_message": null,
  "client_name": "Иван И.",
  "qr_token_url": null,
  "qr_image_url": null,
  "link_expires_at": null,
  "invoice_id": null,
  "refund_amount": "500.00",
  "scan_started_at": null,
  "provider_requested_at": null,
  "expires_at": null,
  "identified_at": null,
  "completed_at": null,
  "refunded_amount": "500.00",
  "receipt_url": null,
  "poll_interval_seconds": null,
  "scan_wait_timeout_seconds": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string | ⚠️ У ссылки статус может вернуться из `awaiting_scan` в `awaiting_customer`: покупатель
не отсканировал код вовремя (это подтвердил Kaspi), и ссылка снова ждёт нажатия (не
более трёх окон на ссылку, `error_code` = `qr_return_scan_timeout`). Это не ошибка и
не терминал.

`failed` — терминал: подтверждение не удалось начать ЛИБО не состоялось
автопроведение ссылки под счёт. Во втором случае `failed` приходит уже ПОСЛЕ
`customer_identified`, и что делать дальше, говорит `error_code` (например,
`refund_insufficient_funds`, `qr_refund_actor_no_longer_authorized`,
`qr_refund_operation_not_found`). Ветвитесь по коду, а не по статусу.
 |
| execution_started_at | string \| null | Когда была взята денежная претензия. Заполнено с `executing` и далее. |
| execution_uncertain_at | string \| null | Когда исход возврата был признан недоказанным. Заполнено только у
`execution_uncertain`.
 |
| error_code | object \| null | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| error_message | string \| null |  |
| client_name | string \| null |  |
| qr_token_url | string \| null | Возвратный QR немедленного старта. ⛔ У сессии, выпущенной как ссылка покупателю,
здесь ВСЕГДА `null`: её цель отдаётся один раз на публичной странице. После
дедлайна и на терминальном статусе поле обнуляется и у немедленного старта.
 |
| qr_image_url | string \| null | Та же цель картинкой; те же правила обнуления |
| link_expires_at | string \| null | Срок НЕАКТИВИРОВАННОЙ ссылки (24 часа с выпуска). Не путать с `expires_at`: тот
появляется только после нажатия покупателя и означает окно скана. Не продлевается
ничем — ни открытием страницы, ни перезагрузкой, ни неудачной попыткой, ни
пропущенным окном. Новое окно после пропущенного открывается только до этого срока.
 |
| invoice_id | integer \| null | Счёт, под который выпущена ссылка (`invoices.id`). `null` — ссылка без привязки. |
| refund_amount | string \| null | Запрошенная сумма возврата по ссылке под счёт, два знака; для return_items — подтверждённая сумма выбранных позиций. `null` — весь остаток счёта
(или ссылка без привязки). Проведённая сумма — `refunded_amount`.
 |
| scan_started_at | string \| null | Начало окна скана. Единственный авторитет дедлайна. |
| provider_requested_at | string \| null | Момент выхода запроса в сеть. У боевой сессии равен `scan_started_at`; у песочной
всегда `null` — сеть там не звалась.
 |
| expires_at | string \| null | Конец текущего окна скана. Не более 90 секунд от `scan_started_at`. У ссылки, вернувшейся в `awaiting_customer` после пропущенного окна, снова `null` (как и `scan_started_at`, `provider_requested_at`) |
| identified_at | string \| null |  |
| completed_at | string \| null |  |
| refunded_amount | string \| null |  |
| receipt_url | string \| null |  |
| poll_interval_seconds | integer \| null | Действующий интервал опроса |
| scan_wait_timeout_seconds | integer \| null | ДЕЙСТВУЮЩЕЕ окно скана в секундах — уже прижатое потолком, а не подсказка
провайдера. ⚠️ Не более 90; раньше здесь могло оказаться около пяти минут.
 |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Ссылка не найдена (not_found) — чужая и несуществующая неотличимы
- `409` — qr_refund_link_not_revocable — покупатель уже подтвердил себя либо ссылка терминальна (и не была отозвана)
- `429` — Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).

⚠️ Поле `message` намеренно осталось прежним — `"Too Many Attempts."`: интеграции,
разбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.

⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит
конкретной ручки (к запросу применяется несколько лимитеров сразу).

- `503` — qr_refund_temporarily_unavailable — отзыв временно невозможен, повторяйте

---

### GET /qr-refunds/{id}

**Response 200:** Снимок сессии

Content-Type: `application/json`

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "execution_started_at": null,
  "execution_uncertain_at": null,
  "error_code": null,
  "error_message": null,
  "client_name": "Иван И.",
  "qr_token_url": null,
  "qr_image_url": null,
  "link_expires_at": null,
  "invoice_id": null,
  "refund_amount": "500.00",
  "scan_started_at": null,
  "provider_requested_at": null,
  "expires_at": null,
  "identified_at": null,
  "completed_at": null,
  "refunded_amount": "500.00",
  "receipt_url": null,
  "poll_interval_seconds": null,
  "scan_wait_timeout_seconds": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string | ⚠️ У ссылки статус может вернуться из `awaiting_scan` в `awaiting_customer`: покупатель
не отсканировал код вовремя (это подтвердил Kaspi), и ссылка снова ждёт нажатия (не
более трёх окон на ссылку, `error_code` = `qr_return_scan_timeout`). Это не ошибка и
не терминал.

`failed` — терминал: подтверждение не удалось начать ЛИБО не состоялось
автопроведение ссылки под счёт. Во втором случае `failed` приходит уже ПОСЛЕ
`customer_identified`, и что делать дальше, говорит `error_code` (например,
`refund_insufficient_funds`, `qr_refund_actor_no_longer_authorized`,
`qr_refund_operation_not_found`). Ветвитесь по коду, а не по статусу.
 |
| execution_started_at | string \| null | Когда была взята денежная претензия. Заполнено с `executing` и далее. |
| execution_uncertain_at | string \| null | Когда исход возврата был признан недоказанным. Заполнено только у
`execution_uncertain`.
 |
| error_code | object \| null | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| error_message | string \| null |  |
| client_name | string \| null |  |
| qr_token_url | string \| null | Возвратный QR немедленного старта. ⛔ У сессии, выпущенной как ссылка покупателю,
здесь ВСЕГДА `null`: её цель отдаётся один раз на публичной странице. После
дедлайна и на терминальном статусе поле обнуляется и у немедленного старта.
 |
| qr_image_url | string \| null | Та же цель картинкой; те же правила обнуления |
| link_expires_at | string \| null | Срок НЕАКТИВИРОВАННОЙ ссылки (24 часа с выпуска). Не путать с `expires_at`: тот
появляется только после нажатия покупателя и означает окно скана. Не продлевается
ничем — ни открытием страницы, ни перезагрузкой, ни неудачной попыткой, ни
пропущенным окном. Новое окно после пропущенного открывается только до этого срока.
 |
| invoice_id | integer \| null | Счёт, под который выпущена ссылка (`invoices.id`). `null` — ссылка без привязки. |
| refund_amount | string \| null | Запрошенная сумма возврата по ссылке под счёт, два знака; для return_items — подтверждённая сумма выбранных позиций. `null` — весь остаток счёта
(или ссылка без привязки). Проведённая сумма — `refunded_amount`.
 |
| scan_started_at | string \| null | Начало окна скана. Единственный авторитет дедлайна. |
| provider_requested_at | string \| null | Момент выхода запроса в сеть. У боевой сессии равен `scan_started_at`; у песочной
всегда `null` — сеть там не звалась.
 |
| expires_at | string \| null | Конец текущего окна скана. Не более 90 секунд от `scan_started_at`. У ссылки, вернувшейся в `awaiting_customer` после пропущенного окна, снова `null` (как и `scan_started_at`, `provider_requested_at`) |
| identified_at | string \| null |  |
| completed_at | string \| null |  |
| refunded_amount | string \| null |  |
| receipt_url | string \| null |  |
| poll_interval_seconds | integer \| null | Действующий интервал опроса |
| scan_wait_timeout_seconds | integer \| null | ДЕЙСТВУЮЩЕЕ окно скана в секундах — уже прижатое потолком, а не подсказка
провайдера. ⚠️ Не более 90; раньше здесь могло оказаться около пяти минут.
 |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Сессия не найдена (not_found)

---

### GET /qr-refunds/{id}/operations

Доступно только после `customer_identified`. Возвращает операции клиента
(возвраты, оплаты — с признаком `returnable`: `none`/`partial`/`full`). Keyset-
пагинация через `cursor` (`has_more`/`next_cursor`).


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| cursor | string | — |  |

**Response 200:** Список операций

Content-Type: `application/json`

```json
{
  "client_name": null,
  "operations": [
    {
      "ref": "",
      "amount": 500,
      "date": null,
      "returnable": "none",
      "source_type": "GOLD",
      "sale_type": "Remote",
      "client_name": "Иван И."
    }
  ],
  "has_more": false,
  "next_cursor": null,
  "remaining_count": 0
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| client_name | string \| null |  |
| operations | object[] |  |
| operations.ref | string | Непрозрачный operation_ref (в details/execute) |
| operations.amount | number |  |
| operations.date | string \| null |  |
| operations.returnable | string |  |
| operations.source_type | string \| null |  |
| operations.sale_type | string \| null |  |
| operations.client_name | string \| null |  |
| has_more | boolean |  |
| next_cursor | string \| null |  |
| remaining_count | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — not_found
- `409` — qr_refund_not_identified / qr_refund_expired / qr_refund_completed
- `429` — Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).

⚠️ Поле `message` намеренно осталось прежним — `"Too Many Attempts."`: интеграции,
разбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.

⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит
конкретной ручки (к запросу применяется несколько лимитеров сразу).


---

### GET /qr-refunds/{id}/operations/{ref}

**Response 200:** Детали операции + возвратные позиции

Content-Type: `application/json`

```json
{
  "ref": "",
  "amount": 0,
  "date": null,
  "returnable": "none",
  "available_for_refund": 500,
  "already_refunded": 0,
  "receipt_url": null,
  "items": [
    {
      "ref": "",
      "name": null,
      "price": 0,
      "count": 0,
      "available_for_refund": 0
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| ref | string |  |
| amount | number |  |
| date | string \| null |  |
| returnable | string |  |
| available_for_refund | number |  |
| already_refunded | number |  |
| receipt_url | string \| null |  |
| items | object[] |  |
| items.ref | string | Непрозрачный item-ref (в execute.items[].ref) |
| items.name | string \| null |  |
| items.price | number |  |
| items.count | number |  |
| items.available_for_refund | number |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — not_found
- `409` — qr_refund_not_identified / qr_refund_expired / qr_refund_completed
- `422` — operation_not_returnable (в т.ч. невалидный ref)
- `429` — Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).

⚠️ Поле `message` намеренно осталось прежним — `"Too Many Attempts."`: интеграции,
разбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.

⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит
конкретной ручки (к запросу применяется несколько лимитеров сразу).


---

### POST /qr-refunds/{id}/execute

Полный возврат — без `amount`/`items`. Частичный — `amount` ИЛИ `items`
(взаимоисключимы).

`200` = возврат выполнен и доказан (терминал), приходит вебхук `qr_refund.completed`.

⛔ **Ошибки делятся на три класса, и разница принципиальна.**

(1) До отправки денег (`403`, `409 qr_refund_not_identified|qr_refund_expired`,
`422 operation_not_returnable|refund_amount_exceeds_available|partial_refund_requires_return_items`,
`503`) сессия остаётся `customer_identified` — денежный запрос не уходил, повторить
с другой операцией или суммой можно.

(2) Kaspi ответил и ОТКАЗАЛ. Деньги не двигались, сессия остаётся
`customer_identified`, вебхука нет, повторить **можно** — на той же сессии, пока не
истёк срок подтверждения покупателя:

- `422 refund_insufficient_funds` — на счёте мерчанта нет денег на возврат; повтор
  имеет смысл после пополнения счёта Kaspi Pay;
- `502 kaspi_error` — Kaspi отказал по другой причине либо ответил неразбираемо.

⚠️ Окно подтверждения покупателя короткое: на той же сессии успевает лишь тот, у кого
деньги на счёте Kaspi Pay появляются сразу, — иначе создавайте новую сессию возврата.
Число денежных попыток по одной сессии ограничено; исчерпав его, `execute` отвечает
`409 qr_refund_execution_attempts_exhausted` и Kaspi не вызывает вовсе.

(3) Ответ Kaspi не доказал НИЧЕГО. Только здесь `202`, и повторять запрос **нельзя**:

- `202 qr_refund_execution_uncertain` — обрыв, таймаут чтения, неопознанная форма
  ответа. Kaspi мог возврат применить. В теле — снимок сессии в статусе
  `execution_uncertain`, приходит вебхук `qr_refund.execution_uncertain`;
- `202 qr_refund_execution_result_unavailable` — исход сохранён, но состояние сессии
  достоверно неизвестно. Снимка в теле нет намеренно.

Оба разбираются вручную через поддержку. ⛔ `Retry-After` у `202` не отдаётся и
отдаваться не будет: попытка потрачена, и намёк на повтор здесь стоил бы вторых
денег. Заголовок приходит только с `503`, где не ушло ничего.

⚠️ Различайте классы (2) и (3) по КОДУ, а не по HTTP-семейству: `502` здесь значит
«Kaspi отказал», а «неизвестно, что произошло» отвечает только `202`.

Повторный `execute` поверх уже идущего возврата → `409 qr_refund_execution_in_progress`,
поверх недоказанного → `409 qr_refund_execution_uncertain`. Денег ни один из них не
двигает.


**Request:**
```json
{
  "operation_ref": "",
  "amount": null,
  "items": null,
  "simulate": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| operation_ref | string | Yes |  |
| amount | number | No | Частичный возврат суммой (взаимоисключимо с items) |
| items | array | No | Частичный возврат по позициям (взаимоисключимо с amount) |
| simulate | object | No | **Только песочница** (боевая → `403 not_sandbox`). Форсирует исход возврата.
 |

**Response 200:** Возврат выполнен и доказан

Content-Type: `application/json`

```json
{
  "id": 0,
  "status": "completed",
  "refunded_amount": "500.00",
  "receipt_url": null,
  "client_name": null,
  "completed_at": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string |  |
| refunded_amount | string |  |
| receipt_url | string \| null |  |
| client_name | string \| null |  |
| completed_at | string \| null |  |

**Response 202:** ⛔ Возврат отправлен, исход не доказан. Повторять запрос НЕЛЬЗЯ.
`qr_refund_execution_uncertain` — со снимком сессии;
`qr_refund_execution_result_unavailable` — без снимка.


Content-Type: `application/json`

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "execution_started_at": null,
  "execution_uncertain_at": null,
  "error_code": "idempotency_conflict",
  "error_message": null,
  "client_name": "Иван И.",
  "qr_token_url": null,
  "qr_image_url": null,
  "link_expires_at": null,
  "invoice_id": null,
  "refund_amount": "500.00",
  "scan_started_at": null,
  "provider_requested_at": null,
  "expires_at": null,
  "identified_at": null,
  "completed_at": null,
  "refunded_amount": "500.00",
  "receipt_url": null,
  "poll_interval_seconds": null,
  "scan_wait_timeout_seconds": null,
  "error": "",
  "message": ""
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string | ⚠️ У ссылки статус может вернуться из `awaiting_scan` в `awaiting_customer`: покупатель
не отсканировал код вовремя (это подтвердил Kaspi), и ссылка снова ждёт нажатия (не
более трёх окон на ссылку, `error_code` = `qr_return_scan_timeout`). Это не ошибка и
не терминал.

`failed` — терминал: подтверждение не удалось начать ЛИБО не состоялось
автопроведение ссылки под счёт. Во втором случае `failed` приходит уже ПОСЛЕ
`customer_identified`, и что делать дальше, говорит `error_code` (например,
`refund_insufficient_funds`, `qr_refund_actor_no_longer_authorized`,
`qr_refund_operation_not_found`). Ветвитесь по коду, а не по статусу.
 |
| execution_started_at | string \| null | Когда была взята денежная претензия. Заполнено с `executing` и далее. |
| execution_uncertain_at | string \| null | Когда исход возврата был признан недоказанным. Заполнено только у
`execution_uncertain`.
 |
| error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| error_message | string \| null |  |
| client_name | string \| null |  |
| qr_token_url | string \| null | Возвратный QR немедленного старта. ⛔ У сессии, выпущенной как ссылка покупателю,
здесь ВСЕГДА `null`: её цель отдаётся один раз на публичной странице. После
дедлайна и на терминальном статусе поле обнуляется и у немедленного старта.
 |
| qr_image_url | string \| null | Та же цель картинкой; те же правила обнуления |
| link_expires_at | string \| null | Срок НЕАКТИВИРОВАННОЙ ссылки (24 часа с выпуска). Не путать с `expires_at`: тот
появляется только после нажатия покупателя и означает окно скана. Не продлевается
ничем — ни открытием страницы, ни перезагрузкой, ни неудачной попыткой, ни
пропущенным окном. Новое окно после пропущенного открывается только до этого срока.
 |
| invoice_id | integer \| null | Счёт, под который выпущена ссылка (`invoices.id`). `null` — ссылка без привязки. |
| refund_amount | string \| null | Запрошенная сумма возврата по ссылке под счёт, два знака; для return_items — подтверждённая сумма выбранных позиций. `null` — весь остаток счёта
(или ссылка без привязки). Проведённая сумма — `refunded_amount`.
 |
| scan_started_at | string \| null | Начало окна скана. Единственный авторитет дедлайна. |
| provider_requested_at | string \| null | Момент выхода запроса в сеть. У боевой сессии равен `scan_started_at`; у песочной
всегда `null` — сеть там не звалась.
 |
| expires_at | string \| null | Конец текущего окна скана. Не более 90 секунд от `scan_started_at`. У ссылки, вернувшейся в `awaiting_customer` после пропущенного окна, снова `null` (как и `scan_started_at`, `provider_requested_at`) |
| identified_at | string \| null |  |
| completed_at | string \| null |  |
| refunded_amount | string \| null |  |
| receipt_url | string \| null |  |
| poll_interval_seconds | integer \| null | Действующий интервал опроса |
| scan_wait_timeout_seconds | integer \| null | ДЕЙСТВУЮЩЕЕ окно скана в секундах — уже прижатое потолком, а не подсказка
провайдера. ⚠️ Не более 90; раньше здесь могло оказаться около пяти минут.
 |
| error | string | Дублирует error_code (общий конверт ошибок) |
| message | string |  |

**Errors:**
- `400` — organization_not_verified
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive / qr_refund_actor_no_longer_authorized / not_sandbox (поле simulate вне песочницы)
- `404` — not_found
- `409` — qr_refund_not_identified / qr_refund_expired / qr_refund_completed / qr_refund_execution_in_progress / qr_refund_execution_uncertain / qr_refund_execution_attempts_exhausted (исчерпан лимит денежных попыток сессии: Kaspi не вызывался, нужна новая сессия)
- `422` — operation_not_returnable / refund_amount_exceeds_available / partial_refund_requires_return_items / refund_insufficient_funds (доказанный отказ Kaspi: деньги не двигались, сессия остаётся customer_identified, повтор допустим, пока жив срок подтверждения покупателя)
- `429` — Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).

⚠️ Поле `message` намеренно осталось прежним — `"Too Many Attempts."`: интеграции,
разбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.

⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит
конкретной ручки (к запросу применяется несколько лимитеров сразу).

- `502` — kaspi_error — Kaspi недоступен, ответил неразбираемо на ревалидации ДО отправки денег либо ДОКАЗАННО отказал в возврате. Деньги не двигались, сессия остаётся customer_identified, повтор допустим
- `503` — `qr_refund_execution_disabled` / `qr_refund_execution_context_unavailable` /
`kaspi_session_invalid` / `kaspi_session_unavailable`. Денежный запрос НЕ уходил,
сессия остаётся `customer_identified`, повтор допустим.


---

### POST /qr-refunds/{id}/simulate

Только sandbox (боевая сессия → `403 not_sandbox`). `identified` — клиент
отсканировал (client_name = "Иван И."); `expired` — QR просрочен. «Не отсканировал,
ждёт» = начальное `awaiting_scan` (просто `GET /qr-refunds/{id}`, без simulate).


**Request:**
```json
{
  "event": "identified"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| event | string | Yes |  |

**Response 200:** Снимок сессии

Content-Type: `application/json`

```json
{
  "id": 42,
  "status": "awaiting_customer",
  "execution_started_at": null,
  "execution_uncertain_at": null,
  "error_code": null,
  "error_message": null,
  "client_name": "Иван И.",
  "qr_token_url": null,
  "qr_image_url": null,
  "link_expires_at": null,
  "invoice_id": null,
  "refund_amount": "500.00",
  "scan_started_at": null,
  "provider_requested_at": null,
  "expires_at": null,
  "identified_at": null,
  "completed_at": null,
  "refunded_amount": "500.00",
  "receipt_url": null,
  "poll_interval_seconds": null,
  "scan_wait_timeout_seconds": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string | ⚠️ У ссылки статус может вернуться из `awaiting_scan` в `awaiting_customer`: покупатель
не отсканировал код вовремя (это подтвердил Kaspi), и ссылка снова ждёт нажатия (не
более трёх окон на ссылку, `error_code` = `qr_return_scan_timeout`). Это не ошибка и
не терминал.

`failed` — терминал: подтверждение не удалось начать ЛИБО не состоялось
автопроведение ссылки под счёт. Во втором случае `failed` приходит уже ПОСЛЕ
`customer_identified`, и что делать дальше, говорит `error_code` (например,
`refund_insufficient_funds`, `qr_refund_actor_no_longer_authorized`,
`qr_refund_operation_not_found`). Ветвитесь по коду, а не по статусу.
 |
| execution_started_at | string \| null | Когда была взята денежная претензия. Заполнено с `executing` и далее. |
| execution_uncertain_at | string \| null | Когда исход возврата был признан недоказанным. Заполнено только у
`execution_uncertain`.
 |
| error_code | object \| null | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| error_message | string \| null |  |
| client_name | string \| null |  |
| qr_token_url | string \| null | Возвратный QR немедленного старта. ⛔ У сессии, выпущенной как ссылка покупателю,
здесь ВСЕГДА `null`: её цель отдаётся один раз на публичной странице. После
дедлайна и на терминальном статусе поле обнуляется и у немедленного старта.
 |
| qr_image_url | string \| null | Та же цель картинкой; те же правила обнуления |
| link_expires_at | string \| null | Срок НЕАКТИВИРОВАННОЙ ссылки (24 часа с выпуска). Не путать с `expires_at`: тот
появляется только после нажатия покупателя и означает окно скана. Не продлевается
ничем — ни открытием страницы, ни перезагрузкой, ни неудачной попыткой, ни
пропущенным окном. Новое окно после пропущенного открывается только до этого срока.
 |
| invoice_id | integer \| null | Счёт, под который выпущена ссылка (`invoices.id`). `null` — ссылка без привязки. |
| refund_amount | string \| null | Запрошенная сумма возврата по ссылке под счёт, два знака; для return_items — подтверждённая сумма выбранных позиций. `null` — весь остаток счёта
(или ссылка без привязки). Проведённая сумма — `refunded_amount`.
 |
| scan_started_at | string \| null | Начало окна скана. Единственный авторитет дедлайна. |
| provider_requested_at | string \| null | Момент выхода запроса в сеть. У боевой сессии равен `scan_started_at`; у песочной
всегда `null` — сеть там не звалась.
 |
| expires_at | string \| null | Конец текущего окна скана. Не более 90 секунд от `scan_started_at`. У ссылки, вернувшейся в `awaiting_customer` после пропущенного окна, снова `null` (как и `scan_started_at`, `provider_requested_at`) |
| identified_at | string \| null |  |
| completed_at | string \| null |  |
| refunded_amount | string \| null |  |
| receipt_url | string \| null |  |
| poll_interval_seconds | integer \| null | Действующий интервал опроса |
| scan_wait_timeout_seconds | integer \| null | ДЕЙСТВУЮЩЕЕ окно скана в секундах — уже прижатое потолком, а не подсказка
провайдера. ⚠️ Не более 90; раньше здесь могло оказаться около пяти минут.
 |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — not_sandbox
- `404` — not_found

---

### GET /webhook-logs

Read-only список доставок вебхуков своей организации — для верификации,
что вебхук по счёту/событию реально ушёл и что ответил приёмник (в т.ч.
для автономных агент-циклов в sandbox). Пагинация — **плоская**
`{current_page, data, total}`. Ре-отправка (retry) здесь недоступна —
только в кабинете.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| invoice_id | integer | — | Фильтр по ID счёта. |
| event | string | — | Фильтр по событию (например `invoice.status_changed`, `invoice.qr_scanned`, `invoice.refunded`). |
| status | string | — | Статус доставки. |
| date_from | string | — |  |
| date_to | string | — |  |
| sort_by | string | created_at |  |
| sort_order | string | desc |  |
| per_page | integer | 20 |  |
| page | integer | 1 |  |

**Response 200:** Список доставок (пустой, если у ключа нет организации)

Content-Type: `application/json`

```json
{
  "current_page": 1,
  "data": [
    {
      "id": 1,
      "api_key_id": 1,
      "api_key_name": "Production",
      "invoice_id": 42,
      "event": "invoice.status_changed",
      "url": "https://example.com/webhook",
      "request_body": "",
      "response_body": null,
      "response_status": 200,
      "status": "success",
      "response_time_ms": 150,
      "error_message": null,
      "created_at": "2026-01-01T12:00:00+05:00",
      "retry_of": null
    }
  ],
  "total": 25
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.api_key_id | integer \| null |  |
| data.api_key_name | string \| null |  |
| data.invoice_id | integer \| null |  |
| data.event | string \| null | Событие вебхука (invoice.status_changed / invoice.qr_scanned / invoice.refunded / ...). |
| data.url | string |  |
| data.request_body | string | Полный отправленный payload (JSON-строка). |
| data.response_body | string \| null | Ответ приёмника (обрезан до 4096 байт). |
| data.response_status | integer \| null |  |
| data.status | string | Итог доставки этой попытки. |
| data.response_time_ms | integer \| null |  |
| data.error_message | string \| null |  |
| data.created_at | string |  |
| data.retry_of | integer \| null | ID исходного лога |
| total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### GET /webhook-logs/{id}

Детали одной доставки (полные `request_body`/`response_body`). Лог чужой
организации → `404` (non-enumeration).


**Response 200:** Доставка вебхука

Content-Type: `application/json`

```json
{
  "id": 1,
  "api_key_id": 1,
  "api_key_name": "Production",
  "invoice_id": 42,
  "event": "invoice.status_changed",
  "url": "https://example.com/webhook",
  "request_body": "",
  "response_body": null,
  "response_status": 200,
  "status": "success",
  "response_time_ms": 150,
  "error_message": null,
  "created_at": "2026-01-01T12:00:00+05:00",
  "retry_of": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| api_key_id | integer \| null |  |
| api_key_name | string \| null |  |
| invoice_id | integer \| null |  |
| event | string \| null | Событие вебхука (invoice.status_changed / invoice.qr_scanned / invoice.refunded / ...). |
| url | string |  |
| request_body | string | Полный отправленный payload (JSON-строка). |
| response_body | string \| null | Ответ приёмника (обрезан до 4096 байт). |
| response_status | integer \| null |  |
| status | string | Итог доставки этой попытки. |
| response_time_ms | integer \| null |  |
| error_message | string \| null |  |
| created_at | string |  |
| retry_of | integer \| null | ID исходного лога |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `404` — Лог не найден (или принадлежит другой организации)

---

### GET /catalog/webhook-logs

Read-only список доставок вебхука `catalog.item_processed` своей организации
(отдельно от `/webhook-logs` — своя таблица, ротация **3 дня**). Пагинация
**плоская** `{current_page, data, total}`. Все логи организации видны любому
её ключу (лог принадлежит организации; орг резолвится из ключа).


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| status | string | — | Итог доставки. |
| catalog_item_id | integer | — | Фильтр по ID позиции каталога. |
| created_after | string | — | Только доставки не раньше указанного момента (ISO 8601). |
| sort_order | string | desc |  |
| per_page | integer | 20 |  |
| page | integer | 1 |  |

**Response 200:** Список доставок (пустой, если у ключа нет организации)

Content-Type: `application/json`

```json
{
  "current_page": 1,
  "data": [
    {
      "id": 1,
      "api_key_id": 1,
      "api_key_name": "Production",
      "catalog_item_id": 12345,
      "event": "catalog.item_processed",
      "url": "https://example.com/webhook",
      "request_body": "",
      "response_body": null,
      "response_status": 200,
      "status": "success",
      "response_time_ms": 150,
      "error_message": null,
      "created_at": "2026-01-01T12:00:00+05:00"
    }
  ],
  "total": 25
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.api_key_id | integer \| null |  |
| data.api_key_name | string \| null |  |
| data.catalog_item_id | integer \| null |  |
| data.event | string |  |
| data.url | string |  |
| data.request_body | string | Полный отправленный payload (JSON-строка). |
| data.response_body | string \| null | Ответ приёмника (обрезан до 1000 байт). |
| data.response_status | integer \| null |  |
| data.status | string | Итог доставки этой попытки. |
| data.response_time_ms | integer \| null |  |
| data.error_message | string \| null |  |
| data.created_at | string |  |
| total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### GET /catalog/queue

Остаток **своей** pending-очереди приёма каталога (POST /catalog) со слим-полями +
блок `queue` с честным ETA в минутах. ETA учитывает общую FIFO-очередь кассира
(несколько организаций на одном кассире). Пагинация **плоская**
`{current_page, data, total, updating, deleting, queue}`. `data`/`total` описывают
только создание; открытые update/delete видны отдельными счётчиками. Скоуп —
организация ключа (чужие строки недоступны). Rate-limit **600/min per key**
(не жжёт общий лимит 200/min).


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| sort_order | string | asc | FIFO — ближайшие к обработке первыми (asc). |
| per_page | integer | 100 |  |
| page | integer | 1 |  |

**Response 200:** Остаток очереди (пустой, если у ключа нет организации → state=not_connected)

Content-Type: `application/json`

```json
{
  "current_page": 1,
  "data": [
    {
      "id": 123,
      "external_ref": "1C-000123",
      "name": "Фильтр масляный",
      "queued_at": "2026-07-11T10:00:00+05:00",
      "source": "own"
    }
  ],
  "total": 1234,
  "updating": 0,
  "deleting": 0,
  "queue": {
    "state": "draining",
    "ahead_in_cashier_queue": 2200,
    "eta_minutes": 11,
    "throttle_retry_in_seconds": null
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.external_ref | string \| null | Клиентская ссылка 1С (ключ маппинга). |
| data.name | string |  |
| data.queued_at | string | Момент постановки в очередь (created_at), Asia/Almaty +05:00. |
| data.source | string | Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`. |
| total | integer |  |
| updating | integer | Позиции с открытой правкой в Kaspi (`operation=update`). Зеркало уже содержит новое значение, поэтому только этот счётчик отличает «правка принята» от «правка подтверждена Kaspi». |
| deleting | integer | Позиции, ожидающие СНЯТИЯ в Kaspi (очередь массового удаления). Считается отдельно: `data`/`total` описывают только приём, и без этого счётчика ответ выглядел бы «очередь пуста», пока тысячи позиций ждут удаления. |
| queue | object | Сводка состояния очереди приёма каталога. `eta_minutes` — целое ТОЛЬКО при
`state=draining` и непустой своей очереди, иначе null. `ahead_in_cashier_queue` —
число pending кассира (всех его организаций) до конца своей очереди включительно.
 |
| queue.state | string | draining — очередь двигается; paused_throttle — пауза из-за троттла Kaspi
(см. throttle_retry_in_seconds); paused_hold — пауза-накопление (hold);
not_connected — у организации нет подключённого кассира; sandbox — sandbox-режим.
 |
| queue.ahead_in_cashier_queue | integer |  |
| queue.eta_minutes | integer \| null |  |
| queue.throttle_retry_in_seconds | integer \| null | Секунды до остывания троттла (только при state=paused_throttle). |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации

---

### GET /catalog/errors

Failed-позиции приёма каталога своей организации с **обезличенными** текстами
ошибок (сырые ответы Kaspi не отдаются). Фильтр по периоду **отказа** (`failed_at`,
то же поле отдаётся в каждой строке ответа); при отсутствии `from` — окно
**последних 7 дней**. Пагинация **плоская** `{current_page, data, total}`.
Rate-limit **600/min per key** (не жжёт общий лимит 200/min).

⛔ Фильтр `batch_id` удалён вместе с агрегатом партий и теперь **отклоняется явно**
(`422 catalog_batch_filter_removed`), а не игнорируется: раньше он сужал выборку до
партии, и молчаливый игнор вернул бы все ошибки организации за окно — ответ,
неотличимый от правды. Отбирайте свои позиции окном `from`/`to` и собственным
списком `external_ref`.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| from | string | — | Начало окна (включительно), по failed_at (момент отказа). Default — now-7d при отсутствии.
Минуты и секунды опциональны (`2026-07-13`, `2026-07-13 10`, `2026-07-13 10:30`).
Голая дата и дата-время без смещения трактуются в **Asia/Almaty** (+05:00) —
календарный день мерчанта; явное смещение берётся как есть.
 |
| to | string | — | Конец окна (включительно). Должен быть >= from, иначе 422. Голая дата
(`2026-07-13`) включает весь день целиком **по Asia/Almaty**; дата-время (минуты
и секунды опциональны) означает ровно этот момент — `10` это `10:00:00`, а не
конец часа. Без смещения трактуется в Asia/Almaty, с явным — берётся как есть.
 |
| sort_order | string | desc |  |
| per_page | integer | 100 |  |
| page | integer | 1 |  |

**Response 200:** Ошибки приёма (пустой, если у ключа нет организации)

Content-Type: `application/json`

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

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| current_page | integer |  |
| data | object[] |  |
| data.id | integer |  |
| data.external_ref | string \| null |  |
| data.name | string |  |
| data.barcode | string \| null |  |
| data.ntin | string \| null |  |
| data.operation | string |  |
| data.error_code | string \| null |  |
| data.error_message | string | Обезличенный клиентский текст (без сырых ответов Kaspi). |
| data.queued_at | string | created_at, +05:00. |
| data.failed_at | string | Момент отказа операции, ISO 8601. |
| data.source | string | Кто завёл позицию, ГЛАЗАМИ спрашивающего: `own` — вы; `shared` — сам мерчант (кабинет) либо позиция приехала из каталога Kaspi; `other` — другая интеграция этого мерчанта. Имя соседней интеграции не отдаётся. `other` можно читать, но не изменять и не удалять (`409 catalog_item_foreign_channel`). Для мерчантских ключей и сессии кабинета значение всегда `own`. |
| total | integer |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Ошибка валидации параметров ЛИБО удалённый фильтр `batch_id` (`error_code: catalog_batch_filter_removed`).

---

### GET /tariff/plans

Список тарифов (start/business/pro/pro_max) и планов (тариф × период 1/3/6 мес). Это подписка мерчанта на ApiPay.

**Response 200:** Тарифы и планы

Content-Type: `application/json`

```json
{
  "tiers": [
    {
      "id": "start",
      "label": "Старт",
      "daily_limit": 30,
      "base_price": 10000,
      "gross_base_price": 10000,
      "partner_discount_percent": 0
    }
  ],
  "plans": [
    {
      "tier_id": "start",
      "period_months": 3,
      "price": 28500,
      "gross_price": 28500,
      "discount_percent": 5,
      "partner_discount_percent": 0,
      "label": "Старт, 3 месяца",
      "price_per_month": 9500
    }
  ],
  "is_custom": false,
  "can_change_tier": true
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| tiers | object[] |  |
| tiers.id | string |  |
| tiers.label | string |  |
| tiers.daily_limit | integer \| null | Лимит счетов в день из каталога тарифов (start 30, business 100, pro 300, pro_max 600). null — лимит в каталоге не задан, объём согласуется индивидуально; ни у одного из действующих тарифов null не встречается. |
| tiers.base_price | integer | Цена месяца к оплате — с партнёрской скидкой организации, если она есть. |
| tiers.gross_base_price | integer | Цена месяца без партнёрской скидки. |
| tiers.partner_discount_percent | integer | Скидка партнёра, привлёкшего организацию, целые проценты; 0 — скидки нет. |
| plans | object[] |  |
| plans.tier_id | string |  |
| plans.period_months | integer |  |
| plans.price | integer | Сумма к оплате за период — после скидки за период и партнёрской скидки. |
| plans.gross_price | integer | Сумма за период без партнёрской скидки; скидка за длинный период учтена. |
| plans.discount_percent | integer | Скидка за длинный период. |
| plans.partner_discount_percent | integer | Скидка партнёра, привлёкшего организацию, целые проценты; 0 — скидки нет. |
| plans.label | string |  |
| plans.price_per_month | number |  |
| is_custom | boolean | true — у организации индивидуальные условия. Строка её тарифа в
`tiers[]` и планы этого тарифа в `plans[]` уже пересчитаны по
договорённости (имя, `daily_limit`, `base_price`, `price`); остальные
тарифы остаются каталожными. Скидок за длинный период у индивидуальной
цены нет: цена периода = цена месяца × месяцев.
 |
| can_change_tier | boolean | false — сменить тариф самостоятельно нельзя, оплата другого тарифа
отдаст `409 custom_tariff_locked`. Продление своего тарифа работает
как обычно.
 |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден

---

### GET /tariff

Снимок подписки мерчанта на ApiPay (lifecycle + суммы). Это подписочная плата
мерчант→ApiPay, **не оборот мерчанта**. Даты — Asia/Almaty `+05:00`.


**Response 200:** Снимок тарифа

Content-Type: `application/json`

```json
{
  "status": "active",
  "tier": "business",
  "tier_label": "Business",
  "daily_limit": 300,
  "is_custom": false,
  "is_trial": false,
  "started_at": "2026-01-01T00:00:00+05:00",
  "expires_at": null,
  "days_remaining": 20,
  "auto_renew": false,
  "freeze": {
    "is_frozen": false,
    "frozen_at": null,
    "paused_days": 0,
    "preserved_days": 0,
    "max_freeze_days": 90,
    "freezes_left": 2
  },
  "last_payment": null,
  "next_payment": {
    "due_at": null,
    "amount": 71250,
    "gross_amount": 71250,
    "partner_discount_percent": 0,
    "tier": "business"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| status | string | `frozen` — организация приостановлена владельцем, и часы тарифа на это время
остановлены: срок не расходуется, а при возвращении мерчанту возвращается
неиспользованный остаток — столько дней, сколько оставалось на момент остановки.
Значение перекрывает `expired` — не показывайте «тариф истёк» и не предлагайте
оплату. Сохранённый срок читайте в `freeze.preserved_days`: `expires_at` и
`days_remaining` при паузе описывают прежнюю дату и могут быть в прошлом.
 |
| tier | string \| null |  |
| tier_label | string | Имя тарифа так, как оно продано ЭТОМУ мерчанту. Обычно совпадает с именем из
каталога (`Business`), но у мерчанта с индивидуальными условиями это его
договорное имя. Витринное поле: switch-логику стройте по `tier`.
 |
| daily_limit | integer \| null | Суточный лимит счетов, действующий у ЭТОГО мерчанта (у индивидуальных условий
он отличается от каталожного). `null` зарезервирован под тариф без лимита,
которого сейчас в каталоге нет.
 |
| is_custom | boolean | true — у организации индивидуальные условия (свой лимит и своя цена).
⛔ `tier` при этом остаётся ОБЫЧНЫМ идентификатором (`pro`), значения `custom`
не существует. Смена тарифа таким мерчантом отбивается `409 custom_tariff_locked`
— переход оформляет поддержка.
 |
| is_trial | boolean |  |
| started_at | string \| null |  |
| expires_at | string \| null |  |
| days_remaining | integer |  |
| auto_renew | boolean |  |
| freeze | object | Состояние паузы тарифа. Блок присутствует всегда; у обычной организации
`is_frozen: false`.
 |
| freeze.is_frozen | boolean |  |
| freeze.frozen_at | string \| null | Момент остановки часов либо null. |
| freeze.paused_days | integer | Сколько дней часы уже стоят. |
| freeze.preserved_days | integer | Сколько дней срока сохранено на момент остановки часов. |
| freeze.max_freeze_days | integer | Предел, дольше которого часы не стоят — дальше срок идёт снова. |
| freeze.freezes_left | integer | Сколько остановок ещё доступно в текущем оплаченном периоде. |
| last_payment | object \| null |  |
| last_payment.amount | integer |  |
| last_payment.paid_at | string \| null |  |
| last_payment.period_months | integer |  |
| last_payment.tier | string |  |
| last_payment.status | string |  |
| next_payment | object |  |
| next_payment.due_at | string \| null | null для expired. |
| next_payment.amount | integer | К оплате — с партнёрской скидкой, если она есть. |
| next_payment.gross_amount | integer | Сумма без партнёрской скидки; скидка за длинный период учтена. |
| next_payment.partner_discount_percent | integer | Скидка партнёра, целые проценты; 0 — скидки нет. |
| next_payment.tier | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Организация не резолвится из ключа

---

### GET /account/health

Состояние аккаунта: API, подключение кассира (в т.ч. `session_status` — так
детектится «слетела Kaspi-сессия» поллингом, без вебхука), тариф и признак
накопления счетов (`invoicing.accumulating` — активный кассир в hold-наборе,
счета копятся и в Kaspi не уходят). Даты — Asia/Almaty `+05:00`.


**Response 200:** Health аккаунта

Content-Type: `application/json`

```json
{
  "api": {
    "status": "ok"
  },
  "connection": {
    "kaspi_connected": true,
    "session_status": "active",
    "session_error_at": null,
    "needs_reauth": false,
    "last_used_at": null
  },
  "tariff": {
    "status": "active",
    "expires_at": null,
    "days_remaining": 20
  },
  "invoicing": {
    "accumulating": false,
    "held_since": null
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| api | object |  |
| api.status | string |  |
| connection | object |  |
| connection.kaspi_connected | boolean |  |
| connection.session_status | string \| null | Здоровье Kaspi-сессии кассира — так детектится «слетела сессия» (поллинг, без вебхука).
`active` — сессия жива (либо ещё ни разу не проверялась); `expired`/`error` — мертва,
нужна переавторизация кассира: до неё создание счетов и чеков отдаёт
`409 kaspi_session_expired`. `null` — активного кассира нет вовсе.
 |
| connection.session_error_at | string \| null | Когда сессия была признана мёртвой (null, если жива). |
| connection.needs_reauth | boolean | true — сессию надо переподключить (эквивалент session_status ∈ {expired, error}). |
| connection.last_used_at | string \| null |  |
| tariff | object |  |
| tariff.status | string |  |
| tariff.expires_at | string \| null |  |
| tariff.days_remaining | integer |  |
| invoicing | object |  |
| invoicing.accumulating | boolean | true — счета копятся (кассир в hold), в Kaspi не уходят. |
| invoicing.held_since | string \| null |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `422` — Организация не резолвится из ключа

---

### GET /connections

Кассиры организации ключа. Требует per-key флаг
`can_manage_cashiers` (иначе `403 cashier_management_disabled`).


**Response 200:** Список кассиров

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 7,
      "label": "Касса №1",
      "kaspi_user_id": "770****4567",
      "session_mode": "self",
      "status": "active",
      "is_primary": true,
      "cashier_phone": null,
      "last_used_at": "2026-01-10T12:00:00+00:00",
      "created_at": "2026-01-01T09:00:00+00:00"
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.label | string \| null | Отображаемое имя кассира: ручное имя (`POST`/`PUT` `label`), если задано, иначе имя по умолчанию. Ручное имя сохраняется при переавторизации кассира. |
| data.kaspi_user_id | string \| null | Маскированный идентификатор. `null` у отключённого кассира (после `auth/logout`). |
| data.session_mode | string | self / external. |
| data.status | string |  |
| data.is_primary | boolean |  |
| data.cashier_phone | string \| null | Маскированный телефон кассира. |
| data.last_used_at | string \| null |  |
| data.created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers

---

### POST /connections

Создаёт pending-кассира (self-hosted) для последующей OTP-авторизации.
Kill-switch мультикассирности и инвариант primary — на сервере.


**Request:**
```json
{
  "label": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| label | string | No | Название кассира. |

**Response 201:** Кассир создан

Content-Type: `application/json`

```json
{
  "data": {
    "id": 7,
    "label": "Касса №1",
    "kaspi_user_id": "770****4567",
    "session_mode": "self",
    "status": "active",
    "is_primary": true,
    "cashier_phone": null,
    "last_used_at": "2026-01-10T12:00:00+00:00",
    "created_at": "2026-01-01T09:00:00+00:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object | Кассир организации. Поля `kaspi_user_id` и `cashier_phone` маскируются. |
| data.id | integer |  |
| data.label | string \| null | Отображаемое имя кассира: ручное имя (`POST`/`PUT` `label`), если задано, иначе имя по умолчанию. Ручное имя сохраняется при переавторизации кассира. |
| data.kaspi_user_id | string \| null | Маскированный идентификатор. `null` у отключённого кассира (после `auth/logout`). |
| data.session_mode | string | self / external. |
| data.status | string |  |
| data.is_primary | boolean |  |
| data.cashier_phone | string \| null | Маскированный телефон кассира. |
| data.last_used_at | string \| null |  |
| data.created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers
- `422` — Гейт мультикассирности (запрет 2-го+ кассира) или валидация

---

### PUT /connections/{connection}

**Request:**
```json
{
  "label": ""
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| label | string | Yes |  |

**Response 200:** Кассир обновлён

Content-Type: `application/json`

```json
{
  "data": {
    "id": 7,
    "label": "Касса №1",
    "kaspi_user_id": "770****4567",
    "session_mode": "self",
    "status": "active",
    "is_primary": true,
    "cashier_phone": null,
    "last_used_at": "2026-01-10T12:00:00+00:00",
    "created_at": "2026-01-01T09:00:00+00:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object | Кассир организации. Поля `kaspi_user_id` и `cashier_phone` маскируются. |
| data.id | integer |  |
| data.label | string \| null | Отображаемое имя кассира: ручное имя (`POST`/`PUT` `label`), если задано, иначе имя по умолчанию. Ручное имя сохраняется при переавторизации кассира. |
| data.kaspi_user_id | string \| null | Маскированный идентификатор. `null` у отключённого кассира (после `auth/logout`). |
| data.session_mode | string | self / external. |
| data.status | string |  |
| data.is_primary | boolean |  |
| data.cashier_phone | string \| null | Маскированный телефон кассира. |
| data.last_used_at | string \| null |  |
| data.created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers
- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)
- `422` — Ошибка валидации

---

### DELETE /connections/{connection}

Помечает кассира inactive (soft delete). Гарды последнего/primary кассира → 422.

⚠️ Денежное последствие. После деактивации возвраты по счетам, оплаченным через этого
кассира, через API не проходят: запрос принимается, но возврат завершается статусом
`failed` — приходит вебхук `invoice.refunded` со `status: failed`. Такие возвраты
проводят вручную в приложении Kaspi Pay. Проведите нужные возвраты до деактивации.


**Response 200:** Кассир деактивирован

Content-Type: `application/json`

```json
{
  "success": true
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| success | boolean |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers
- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)
- `422` — Нельзя удалить последнего/primary кассира

---

### POST /connections/{connection}/primary

**Response 200:** Кассир назначен primary

Content-Type: `application/json`

```json
{
  "data": {
    "id": 7,
    "label": "Касса №1",
    "kaspi_user_id": "770****4567",
    "session_mode": "self",
    "status": "active",
    "is_primary": true,
    "cashier_phone": null,
    "last_used_at": "2026-01-10T12:00:00+00:00",
    "created_at": "2026-01-01T09:00:00+00:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object | Кассир организации. Поля `kaspi_user_id` и `cashier_phone` маскируются. |
| data.id | integer |  |
| data.label | string \| null | Отображаемое имя кассира: ручное имя (`POST`/`PUT` `label`), если задано, иначе имя по умолчанию. Ручное имя сохраняется при переавторизации кассира. |
| data.kaspi_user_id | string \| null | Маскированный идентификатор. `null` у отключённого кассира (после `auth/logout`). |
| data.session_mode | string | self / external. |
| data.status | string |  |
| data.is_primary | boolean |  |
| data.cashier_phone | string \| null | Маскированный телефон кассира. |
| data.last_used_at | string \| null |  |
| data.created_at | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers
- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)
- `422` — `connection_identity_unverified` — connection ещё не подтвердила полную business identity и не может стать primary.

---

### POST /connections/{connection}/auth/init

Шаг 1 из 3 self-hosted авторизации. `force=true` — переавторизация поверх живой
сессии (при слёте). Лимит **5/мин**. External-mode
кассир → `409 external_mode_not_reauthable`. Тестовая (sandbox) организация
партнёра → `409 test_organization`: реальный кассир к ней не привязывается,
песочница проходится мок-контуром Partner API.


**Request:**
```json
{
  "force": false
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| force | boolean | No |  |

**Response 200:** Процесс авторизации начат

Content-Type: `application/json`

```json
{
  "process_id": "a1b2c3d4"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| process_id | string |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `cashier_management_disabled` — у ключа нет флага `can_manage_cashiers`.
`kyc_required` — анкета «Расскажите о бизнесе» ещё не одобрена: подключить
кассира можно после одобрения. Повтор до одобрения бесполезен. Переподключение
уже привязанного кассира этим кодом не отбивается.

- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)
- `409` — `external_mode_not_reauthable` — external-кассира нельзя переавторизовать
через этот API. `test_organization` — организация тестовая (`is_test`),
реальная авторизация кассира для неё запрещена: состояние постоянное,
ретрай бессмыслен.

- `503` — `entrance_auth_disabled` — подключение/переавторизация кассира временно недоступны
(технические работы). Повторите позже; уже подключённые кассиры продолжают работать.


---

### POST /connections/{connection}/auth/send-phone

Шаг 2 из 3. Kaspi отправляет OTP на номер кассира. Лимит **5/мин**.

**Request:**
```json
{
  "phoneNumber": "77001234567",
  "confirm_duplicate": false
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| phoneNumber | string | Yes | Телефон кассира, формат 7XXXXXXXXXX. |
| confirm_duplicate | boolean | No | Подтверждение осознанной перепривязки: этот кассир уже подключён к ДРУГОЙ организации того же владельца. Без флага такой запрос отбивается `409 duplicate_cashier_confirm` с описанием существующей организации. Флаг действует только на организации того же владельца. |

**Response 200:** SMS отправлена

Content-Type: `application/json`

```json
{
  "success": true
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| success | boolean |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `cashier_management_disabled` — у ключа нет флага `can_manage_cashiers`.
`kyc_required` — анкета «Расскажите о бизнесе» ещё не одобрена: SMS кассиру
не отправляется вовсе. Повтор до одобрения бесполезен; переподключение уже
привязанного кассира (тот же номер) этим кодом не отбивается.
`cashier_change_requires_merchant` — ключ выдан партнёром на организацию, аккаунтом
которой партнёр не владеет, и номер отличается от телефона текущего кассира.
Переавторизация ТОГО ЖЕ кассира и первое подключение (кассира ещё нет) проходят;
смену кассира проводит сам мерчант — своей дверью либо по ссылке-приглашению.
Повтор запроса не поможет.

- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)
- `409` — `context_expired` — контекст процесса протух либо Kaspi его закрыл: нужен новый
`init`, повторять `send-phone` бесполезно. `no_process` — авторизация не начата
(или уже закрыта предыдущим терминальным исходом). `cashier_unavailable` —
кассира сейчас нельзя подключить; причина намеренно не раскрывается, повтор
не поможет, обратитесь в поддержку. `test_organization` — организация
тестовая (`is_test`): реальный кассир к ней не привязывается.
`duplicate_cashier_confirm` — кассир уже подключён к ДРУГОЙ
организации ТОГО ЖЕ владельца; это не блокировка, а подтверждение:
повторите запрос с `confirm_duplicate: true`, если перепривязка намеренная.
Тело содержит `can_override` и `existing_organization`
(`connection_status` — статус коннекшна: active|inactive|pending|error).

- `422` — Ошибка формата `phoneNumber` (стандартная Laravel-форма валидации) либо
`not_cashier` (у номера есть пароль/роли сверх кассира), `not_registered`
(номер не заведён кассиром в Kaspi).

- `429` — `rate_limited` — за сутки с этого аккаунта пробовали слишком много РАЗНЫХ
номеров кассиров. ⚠️ Окно СУТОЧНОЕ: `Retry-After` содержит секунды до
обнуления счётчика (часы, не минуты) — повторять раньше бесполезно.
Уже подключённые кассиры этого владельца в счётчик не входят, поэтому
переавторизация рабочей точки лимитом не блокируется.

- `502` — `sms_failed` — Kaspi не вернул экран ввода OTP.
- `503` — `entrance_auth_disabled` — подключение кассира временно недоступно (техработы).
`kaspi_busy` — Kaspi троттлит анти-абузом; сессия закрыта, после паузы нужен
новый `init`. Отличается от `EntranceAuthDisabled` наличием второй причины —
используется на шагах, где Kaspi может ответить троттлом (`send-phone`, `verify-otp`).


---

### POST /connections/{connection}/auth/verify-otp

Шаг 3 из 3 (point of no return). Лимит **10/мин**. При
неверном OTP — `200` с `success:false` (можно повторить). При успехе — `success:true`
и сессия кассира активирована.

⚠️ **Не всякий неуспех повторяем.** Kaspi может код **принять** и увести на
универсальную регистрацию («введите ИИН») — это значит, что номер не заведён
кассиром. Тогда шаг отдаёт **`422 not_registered`**, а не `200`: сессия
авторизации закрыта, повторять OTP или слать SMS заново бессмысленно —
следующий `send-phone` вернёт `409 no_process`. Нужен новый `init` после того,
как номер добавлен кассиром в Kaspi Pay → Кассиры. Так же терминальны
`409 context_expired` (Kaspi потерял контекст → новый `init`) и
`503 kaspi_busy` (анти-абуз Kaspi → пауза по `Retry-After`, затем новый `init`).


**Request:**
```json
{
  "otp": "123456"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| otp | string | Yes | Код из SMS, 4–6 цифр. |

**Response 200:** Результат проверки OTP (success true/false)

Content-Type: `application/json`

```json
{
  "success": true,
  "mode": "self",
  "org_name": null,
  "phone": "770****4567",
  "sandbox_mode": false,
  "catalog_block_reason": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| success | boolean |  |
| mode | string | Только при success=true. |
| org_name | string \| null | Только при success=true. |
| phone | string | Маскированный телефон кассира (при success=true). |
| sandbox_mode | boolean |  |
| catalog_block_reason | string \| null | Почему каталог товаров этой организации не заработает; `null` — блокировки нет
(или каталог организации не нужен). Отдаётся только при `success=true`.

`idn_conflict` — legacy-маркер старого collision-flow; новые auth
конфликты возвращаются отдельным HTTP 409 и этот marker не пишут;
решается поддержкой.
`no_idn` — Kaspi не вернул БИН организации.
`no_kaspi_org_id` — не получен контекст организации Kaspi.
`no_tradepoint` — код торговой точки организации в Kaspi определить
не удалось.

Во всех четырёх случаях каталог останется пустым до вмешательства поддержки:
`GET /catalog` будет отдавать `200` с пустым списком.

⚠️ Список значений ОТКРЫТ и может пополняться. Обрабатывайте неизвестный код
общей веткой («каталог недоступен»), а не `switch` без `default`.
 |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `cashier_management_disabled` — у ключа нет флага `can_manage_cashiers`.
`kyc_required` — терминально: анкета организации не одобрена (редкий случай —
одобрение отозвали между `send-phone` и `verify-otp`). Kaspi код при этом уже
принял, но подключение не создаётся; сессия закрыта, нужен новый `init` после
одобрения анкеты.

- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)
- `409` — Терминально: `context_expired` — Kaspi потерял контекст процесса (нужен новый
`init`, а не повтор OTP); `no_process` — авторизация не начата либо уже
закрыта предыдущим терминальным исходом; `test_organization` — организация
тестовая (`is_test`), реальная авторизация кассира для неё запрещена;
`organization_identity_conflict` — Kaspi вернул другую полную
identity либо та же пара уже закреплена за другой Organization.
Cashier auth не меняет владельца и не раскрывает данные чужой
организации.

- `422` — Ошибка формата `otp` (стандартная Laravel-форма валидации) либо терминальный
`not_registered` — Kaspi принял код, но увёл на регистрацию пользователя:
номер не заведён кассиром. Сессия закрыта, ретрай бессмыслен.

- `502` — `organization_identity_unavailable` — Kaspi не вернул единую полную пару IDN + OrganizationId. Ни session, ни Organization не изменены.
- `503` — `entrance_auth_disabled` — подключение кассира временно недоступно (техработы).
`kaspi_busy` — Kaspi троттлит анти-абузом; сессия закрыта, после паузы нужен
новый `init`. Отличается от `EntranceAuthDisabled` наличием второй причины —
используется на шагах, где Kaspi может ответить троттлом (`send-phone`, `verify-otp`).


---

### GET /connections/{connection}/auth/status

**Response 200:** Статус сессии

Content-Type: `application/json`

```json
{
  "connection_id": 7,
  "mode": "self",
  "status": "active",
  "auth_status": "active",
  "attempt_status": "none",
  "phone": "770****4567",
  "last_used_at": "2026-01-10T12:00:00+00:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| connection_id | integer |  |
| mode | string |  |
| status | string | Статус кассира (напр. active/pending/inactive). |
| auth_status | string | Статус self-сессии (none, если сессии нет). |
| attempt_status | string | Статус отдельной попытки авторизации; когда попытки нет — строка `none`, не null. Рабочая session в начале reauth не очищается. Значения контрактом не фиксированы — ветвитесь по `status`/`auth_status`, а не по этому полю. |
| phone | string \| null | Маскированный телефон. |
| last_used_at | string \| null |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers
- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)

---

### POST /connections/{connection}/auth/logout

Отключает кассира: сессия и незавершённая попытка авторизации закрываются, подключение
переходит в `status: inactive` и `session_mode: external`, а `kaspi_user_id`
освобождается — тот же номер после этого можно подключить в другой организации.
Строка кассира остаётся в `GET /connections`: это не `DELETE /connections/{connection}`,
и отключить можно и основного, и единственного кассира (у `DELETE` это `422`).
Идемпотентно: повтор на уже отключённой строке → `200`. Отдельного лимита у метода нет —
действует общий лимит v1. После отключения `auth/init` снова подключает эту же строку.

⚠️ Пока кассир отключён, приём платежей этой точкой не работает: создание счетов
отвечает `kaspi_session_not_configured`.

⚠️ Денежное последствие. После отключения возвраты по счетам, оплаченным через этого
кассира, через API не проходят: запрос принимается, но возврат завершается статусом
`failed` — приходит вебхук `invoice.refunded` со `status: failed`. Такие возвраты
проводят вручную в приложении Kaspi Pay. Проведите нужные возвраты до отключения.


**Response 200:** Кассир отключён (или уже был отключён)

Content-Type: `application/json`

```json
{
  "success": true
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| success | boolean |  |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — У ключа нет флага can_manage_cashiers
- `404` — Кассир не найден (или принадлежит другой организации — non-enumeration)

---

### POST /catalog/bulk-delete

Снимает с продажи много позиций одним запросом. **POST, а не DELETE** — тело у
`DELETE` плохо поддержано HTTP-клиентами 1С.

Цели задаются ровно одним списком: `ids[]` либо `external_refs[]`. Оба ключа уникальны в
пределах боевой/песочной оси организации, поэтому одно значение = не больше одной позиции.
Лимит — **200** значений, превышение → `422 catalog_match_overflow`.
⛔ `barcodes[]` не принимается: штрихкод не уникален, и одно значение могло бы снести сотни позиций.
Filter-режима нет: сервер не додумывает разрушающее множество по неполной заливке.

⛔ **Интеграция снимает только СВОИ позиции.** Каталог у мерчанта общий, и то, что
завёл он сам или другая его интеграция, одним вашим запросом не сносится. Такие
идентификаторы вернутся в поле `not_yours` и останутся на месте — во всех телах ответа,
включая `dry_run`. Ключа самого мерчанта и запросов из кабинета это не касается.

⚠️ **Это фоновая операция на СУТКИ.** Позиции снимаются с продажи по одной, и во
время массового удаления заливка каталога идёт медленнее.
Порядок величин: 500 позиций — около двух часов, несколько тысяч — около
суток с небольшим; при параллельной заливке умножьте примерно вчетверо.
Ответ `202` — это «принято в работу», а не «удалено». Общего хендла операции нет:
остаток смотрите в `GET /catalog/queue`, строки — через targeted `GET /catalog`,
отказы — в `GET /catalog/errors`; итог каждой строки присылает
`catalog.item_processed`. ⛔ Не ставьте на этот запрос HTTP-таймаут в расчёте на завершение.

⚠️ **Позиции, попавшие в очередь снятия, перестают приниматься в корзину сразу** —
включая уже напечатанные листы `POST /static-qr`, где эти позиции зашиты в
`cart_items`. Скан такого листа не создаст счёт, пока позиция не вернётся в продажу,
а переиздать напечатанный лист нельзя. Если под удаление попадают позиции из
действующих листов, верните их через `POST /catalog` либо перевыпустите листы.

**Полная синхронизация из 1С:** залейте актуальное, постройте у себя явный список ушедших
`ids`/`external_refs`, проверьте каждый чанк через `dry_run`, затем поставьте его в очередь с уникальным
`Idempotency-Key`. Необязательный `expected_count` из ответа разведки защитит от
изменения множества между проверкой и запуском. Подробный рецепт — раздел «Массовое удаление» в документации каталога.

⚠️ Позицию, попавшую в очередь удаления по ошибке, можно **вернуть**: пришлите её
обычным `POST /catalog` — удаление отменится, и если товар ещё не снят в Kaspi, он
просто останется на месте. Если снятие уже прошло, позиция заводится заново: по
`external_ref` вернётся та же строка (тот же `id`), а у позиции без `external_ref`,
штрихкода и НТИН в каталоге появится новая строка.

⛔ Возврат неоднозначен только если у позиции нет `external_ref`, а её штрихкод
совпадает с ДРУГИМ живым товаром: присланная позиция сольётся с живым товаром, а
приговорённая уйдёт по вашему плану. Возврат по `external_ref` однозначен всегда.
Отдельного отказа «снятие уже отправлено» больше нет: новое намерение принимается,
а фон при необходимости переиздаёт позицию после подтверждения удаления.


**Request:**
```json
{
  "ids": [
    0
  ],
  "external_refs": [
    ""
  ],
  "expected_count": 0,
  "dry_run": false,
  "idempotency_key": ""
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| ids | array | No | Режим списка: id позиций из GET /catalog. |
| external_refs | array | No | Режим списка: клиентские ссылки (1С). |
| expected_count | integer | No | Необязательная сверка с would_delete из dry_run. Расхождение → 409, ничего не меняется. |
| dry_run | boolean | No | true — только посчитать и показать образец, ничего не менять и не расходовать Idempotency-Key. |
| idempotency_key | string | No |  |

**Response 200:** Четыре РАЗНЫХ тела под одним кодом — различайте по наличию ключей:
`dry_run: true` — разведка; `idempotent_replay: true` — точный повтор;
`queued: 0` — удалять нечего; `deleted` — песочница (удалено синхронно).


Content-Type: `application/json`

```json
{
  "dry_run": true,
  "would_delete": 412,
  "sample": [
    {
      "id": 0,
      "external_ref": null,
      "name": ""
    }
  ],
  "already_queued": 0,
  "already_queued_count": 0,
  "not_yours": [
    0
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| dry_run | boolean |  |
| would_delete | integer |  |
| sample | object[] |  |
| sample.id | integer |  |
| sample.external_ref | string \| null |  |
| sample.name | string |  |
| already_queued | integer | ⚠️ Здесь ЧИСЛО (историческая форма). В остальных телах — массив id. |
| already_queued_count | integer |  |
| not_yours | integer[] | Позиции запроса, которые НЕ заведены вашей интеграцией: их завёл сам мерчант, они приехали из каталога Kaspi либо их завела другая его интеграция. Удалены они не будут. Пусто, если запрос идёт ключом самого мерчанта или из кабинета — у них чужих позиций не бывает. |

**Response 202:** Позиции приняты в очередь удаления

Content-Type: `application/json`

```json
{
  "queued": 180,
  "buried": 0,
  "already_queued": [
    0
  ],
  "already_queued_count": 0,
  "not_yours": [
    0
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| queued | integer | Сколько позиций принято в очередь удаления этим запросом (не больше 200 — столько значений принимает список). |
| buried | integer | Сколько недоставленных призрачных строк закрыто локально. |
| already_queued | integer[] | Позиции, которые уже стояли в очереди удаления — их не переклеймливаем. ⚠️ Список ОБРЕЗАН до 200 элементов (это образец, а не полный перечень); полное число всегда в `already_queued_count`. |
| already_queued_count | integer | Сколько позиций уже стояло в очереди удаления — без обрезки. |
| not_yours | integer[] | Позиции запроса, которые НЕ заведены вашей интеграцией: их завёл сам мерчант, они приехали из каталога Kaspi либо их завела другая его интеграция. Удалены они не будут. Пусто, если запрос идёт ключом самого мерчанта или из кабинета — у них чужих позиций не бывает. |

**Errors:**
- `400` — `catalog_not_supported` — у организации не включён каталог. ⚠️ Именно 400, а не 422:
это предусловие организации, а не ошибка тела запроса.

- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — тариф не активен.
`catalog_delete_owner_key_required` — ключ выпущен не владельцем организации.

- `404` — Организация не найдена либо не верифицирована. ⚠️ Тело исторически без
`error_code`: `{"error": "Verified organization not found"}`.

- `409` — `idempotency_key_conflict` — ключ занят другим телом либо другой каталожной операцией.
`catalog_multi_tradepoint` — у организации несколько торговых точек.
`catalog_busy` — каталог занят другой операцией, повторите через несколько секунд.
`catalog_bulk_delete_mismatch` — необязательный `expected_count` не совпал с
фактическим числом строк; повторите `dry_run`. У этого отказа в теле дополнительно
приходят `expected_count` (что прислали) и `actual_count` (сколько строк нашлось);
не удалено ничего.

- `422` — `catalog_delete_scope_required` — не задан ни `ids[]`, ни `external_refs[]`, либо заданы оба списка.
`catalog_match_overflow` — слишком много значений в списке.

- `429` — Превышен ПОМИНУТНЫЙ лимит запросов (`error_code: request_rate_limited`).

⚠️ Поле `message` намеренно осталось прежним — `"Too Many Attempts."`: интеграции,
разбиравшие строку, продолжают работать. Всё машиночитаемое добавлено рядом.

⚠️ `limit`/`X-RateLimit-Limit` — это бакет, где осталось меньше всего, а не лимит
конкретной ручки (к запросу применяется несколько лимитеров сразу).


---

### POST /catalog/scan

Резолвит штрихкод в Нацкаталоге Kaspi (синхронно, на сессии кассира). Один штрихкод
может вернуть несколько кандидатов (общий gtin, разные ntin). Пустой `data[]` и/или
`scan_result.code != "ok"` = товар не найден (**это не ошибка**, `200`; создавайте
товар обычным путём без ntin/gtin). Лимиты: **30/мин + 2000/сутки** на ключ.
При троттле Kaspi возвращается `429 kaspi_throttled`. Дождитесь времени из
`retry_after_seconds` (заголовок `Retry-After`) и повторите.


**Request:**
```json
{
  "input": "4607015232646"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| input | string | Yes | Штрихкод (ручной ввод или сканер). |

**Response 200:** Результат резолва (пустой data[] = не найдено, не ошибка)

Content-Type: `application/json`

```json
{
  "data": [
    {
      "id": 1118196,
      "name": "ВАФЛИ ЯШКИНО ОРЕХОВЫЕ 300Г",
      "ntin": "0200009461097",
      "gtin": "4607015232646",
      "barcode": "4607015232646",
      "unit_id": null,
      "image_link": null
    }
  ],
  "normalized_barcode": "4607015232646",
  "scan_result": {
    "code": "ok",
    "message": null
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| data | object[] |  |
| data.id | integer |  |
| data.name | string |  |
| data.ntin | string \| null |  |
| data.gtin | string \| null |  |
| data.barcode | string \| null |  |
| data.unit_id | integer \| null |  |
| data.image_link | string \| null |  |
| normalized_barcode | string | Точное эхо input; имя legacy, нормализация не обещается. |
| scan_result | object |  |
| scan_result.code | string |  |
| scan_result.message | string \| null |  |

**Errors:**
- `400` — kaspi_session_expired — сессия кассира истекла, нужна переавторизация
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `422` — Ошибка валидации
- `429` — kaspi_throttled — Kaspi троттлит сессию. Тело содержит retry_after_seconds, заголовок Retry-After.
- `503` — kaspi_scan_unavailable — Нацкаталог временно недоступен, повторите позже

---

### POST /clients/check

Проверяет, зарегистрирован ли номер в Kaspi, и возвращает имя клиента в формате
Kaspi «Имя Ф.» (полная фамилия не отдаётся). Удобно перед созданием счёта/подписки.

Номер нормализуется к `8XXXXXXXXXX` (77.../87.../+77... с пробелами/дефисами).

**Лимиты:** 60/мин + 10 000/сутки на ключ; 200/мин + 20 000/сутки на организацию;
10/мин на одного кассира. При троттле Kaspi — `429 scope=kaspi_throttle`.
Дождитесь времени из `retry_after_seconds` (заголовок `Retry-After`) и повторите.

⚠️ **Запрещён массовый перебор** (enumeration). При злоупотреблении ключ
деактивируется без предупреждения.
Sandbox: `87770000001` → true/"Иван И.", `87770000002` → false, иначе false.


**Request:**
```json
{
  "phone": "77001234567"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| phone | string | Yes | Телефон клиента (после нормализации — 11 цифр). |

**Response 200:** Номер проверен

Content-Type: `application/json`

```json
{
  "phone": "87001234567",
  "has_kaspi": true,
  "client_name": "Иван И."
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| phone | string | Нормализованный номер (8XXXXXXXXXX). |
| has_kaspi | boolean |  |
| client_name | string \| null | Формат «Имя Ф.»; null если has_kaspi=false. |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `422` — Телефон пустой или не нормализуется к 11 цифрам
- `429` — Превышен лимит. Два РАЗНЫХ источника, тела похожи, но различаются полем `scope`:

* **наш лимитер** — `scope` одно из `minute`, `day`, `org_minute`, `org_day`,
  `cashier_minute`; список открыт, неизвестное значение обрабатывайте общей веткой.
  ⚠️ Часть окон СУТОЧНЫЕ (`*_day`) — ориентируйтесь на `retry_after`, а не на
  «подожду минуту»;
* **троттл Kaspi** — `scope: kaspi_throttle` (сработал circuit-breaker).

Оба тела несут `retry_after` (секунды) и заголовок `Retry-After`.
⚠️ Поля `error_code` здесь нет намеренно: единый поминутный код сюда не подходит —
окна разные.

- `503` — Kaspi session unavailable — нет активной сессии кассира

---

### POST /subscriptions/{id}/skip-period/preview

Превью пропуска периода: какой период закроется, будет ли запрошена отмена его счёта и
когда придёт следующий. Ничего не меняет. Значение `skip.billing_period_start` из ответа
передайте в `POST /subscriptions/{id}/skip-period` — так действие пропустит ровно тот
период, который вы показали человеку.

Пропустить можно только `active` подписку, в том числе в повторах и в льготном
периоде. `paused`, `cancelled`, `expired` и подписка, получившая все оплаты
`total_cycles`, отвечают `409`.


**Response 200:** Что будет пропущено

Content-Type: `application/json`

```json
{
  "skip": {
    "kind": "live_invoice",
    "billing_period_start": "2026-09-04",
    "billing_period_end": "2026-10-03",
    "invoice": null,
    "next_billing_at": null,
    "next_billing_label": "через 32 дня",
    "next_billing_in_days": 32,
    "resets_retries": false,
    "replayed": false
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| skip | object | Какой период пропускается и что будет дальше. Одна форма у превью и у действия. |
| skip.kind | string | `live_invoice` — счёт периода уже выставлен; пропуск запросит его отмену в Kaspi;
`open_retry` — период не оплачен и ещё открыт (идут повторы или льготный период);
`next_period` — ближайший период, счёт за который ещё не выставлялся.
 |
| skip.billing_period_start | string |  |
| skip.billing_period_end | string |  |
| skip.invoice | object \| null | Счёт периода, отмену которого запрашивает пропуск; `null`, если отменять нечего. |
| skip.invoice.id | integer |  |
| skip.invoice.amount | string |  |
| skip.invoice.status | string | В превью — текущий статус счёта; в ответе действия — статус сразу после запроса отмены: обычно `cancelling` (в песочнице — сразу `cancelled`), `paid` — если покупатель успел оплатить. Итог отмены приходит `invoice.status_changed`. |
| skip.next_billing_at | string \| null | Когда придёт счёт следующего периода. Бывает в прошлом: тогда счёт текущего периода выставится без ожидания следующей даты. |
| skip.next_billing_label | string \| null |  |
| skip.next_billing_in_days | integer \| null |  |
| skip.resets_retries | boolean | Пропуск снимет неудачные попытки или льготный период. |
| skip.replayed | boolean | Только в ответе действия: `true` — этот период уже был пропущен раньше, ничего не изменено; тогда `kind` и `invoice` описывают его текущее состояние, а не то, что показывало превью. |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Подписка не найдена
- `409` — `subscription_not_active` / `subscription_cycles_exhausted` — пропускать нечего.

---

### POST /subscriptions/{id}/skip-period

Закрывает период без оплаты — например, покупатель заплатил продавцу другим способом.
Подписка остаётся активной: попытки и льготный период сбрасываются, следующий период
выставляется по расписанию. В истории платежей у периода появляется строка
`status: skipped`, приходит вебхук `subscription.period_skipped`.

Если счёт за период уже выставлен, ApiPay отправляет его отмену в Kaspi — так же, как
`POST /invoices/{id}/cancel`: итог придёт `invoice.status_changed` (`cancelled` или
`error`), а если Kaspi отмену не примет, счёт тихо вернётся в `pending` и останется
доступен покупателю. `subscription.payment_failed` по этому счёту не придёт. Если
покупатель оплатит счёт — до отмены или после отказа Kaspi в ней, — период
засчитывается оплаченным. Пропуск не считается оплатой: подписка с `total_cycles`
проработает на период дольше.

Если покупатель оплатил счёт пропущенного периода, придёт
`subscription.payment_succeeded` с `invoice_id`, равным `cancelled_invoice_id` из
`subscription.period_skipped`. События могут прийти в любом порядке — итог периода
`paid`. Если покупатель уже рассчитался с вами другим способом, лишнюю оплату верните
сами через `POST /invoices/{id}/refund`.

⚠️ Сначала вызовите превью (`POST /subscriptions/{id}/skip-period/preview`) и передайте
его `billing_period_start`. Повтор запроса с тем же значением безопасен: пропуск не
повторится, ответ придёт с `skip.replayed: true`. Если период успел смениться
(например, вышел новый счёт), ответ — `409 skip_period_changed` со свежим `skip`:
покажите его человеку и повторите с новым значением.


**Request:**
```json
{
  "billing_period_start": "2026-09-04"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| billing_period_start | date | Yes | `skip.billing_period_start` из превью — подтверждает, какой период пропустить. |

**Response 200:** Период пропущен (или уже был пропущен этим же запросом — `skip.replayed`)

Content-Type: `application/json`

```json
{
  "message": "Period skipped",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  },
  "skip": {
    "kind": "live_invoice",
    "billing_period_start": "2026-09-04",
    "billing_period_end": "2026-10-03",
    "invoice": null,
    "next_billing_at": null,
    "next_billing_label": "через 32 дня",
    "next_billing_in_days": 32,
    "resets_retries": false,
    "replayed": false
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |
| skip | object | Какой период пропускается и что будет дальше. Одна форма у превью и у действия. |
| skip.kind | string | `live_invoice` — счёт периода уже выставлен; пропуск запросит его отмену в Kaspi;
`open_retry` — период не оплачен и ещё открыт (идут повторы или льготный период);
`next_period` — ближайший период, счёт за который ещё не выставлялся.
 |
| skip.billing_period_start | string |  |
| skip.billing_period_end | string |  |
| skip.invoice | object \| null | Счёт периода, отмену которого запрашивает пропуск; `null`, если отменять нечего. |
| skip.invoice.id | integer |  |
| skip.invoice.amount | string |  |
| skip.invoice.status | string | В превью — текущий статус счёта; в ответе действия — статус сразу после запроса отмены: обычно `cancelling` (в песочнице — сразу `cancelled`), `paid` — если покупатель успел оплатить. Итог отмены приходит `invoice.status_changed`. |
| skip.next_billing_at | string \| null | Когда придёт счёт следующего периода. Бывает в прошлом: тогда счёт текущего периода выставится без ожидания следующей даты. |
| skip.next_billing_label | string \| null |  |
| skip.next_billing_in_days | integer \| null |  |
| skip.resets_retries | boolean | Пропуск снимет неудачные попытки или льготный период. |
| skip.replayed | boolean | Только в ответе действия: `true` — этот период уже был пропущен раньше, ничего не изменено; тогда `kind` и `invoice` описывают его текущее состояние, а не то, что показывало превью. |

**Errors:**
- `401` — X-API-Key отсутствует или невалиден
- `403` — `tariff_inactive` — подписка мерчанта на ApiPay не активна, платные операции закрыты.
Лечится оплатой тарифа в кабинете apipay.kz; грейс-периода нет — блокировка наступает
сразу после `expires_at`. Состояние видно заранее в `GET /tariff` и
`GET /account/health` → `tariff.status`. Чтение (`GET`) продолжает работать,
закрываются только действия.

- `404` — Подписка не найдена
- `409` — `subscription_not_active`, `subscription_cycles_exhausted` — пропускать нечего;
`subscription_busy` — по подписке прямо сейчас выставляется счёт, повторите через
минуту; `skip_period_changed` — период сменился, в теле свежий `skip`.

- `422` — Ошибка валидации

---

### POST /subscriptions/{subscription}/simulate-invoice

**Только sandbox.** Создаёт один sandbox-счёт для активной sandbox-подписки и шлёт
вебхук. Кулдаун 30 секунд.

У подписки с корзиной сумма и состав считаются ТЕМ ЖЕ путём, что боевое списание —
по актуальным ценам каталога, а не по хранимому `amount`. Поэтому переоценка позиции
меняет сумму следующей симуляции, а непригодная позиция даёт `422`.


**Response 201:** Sandbox-счёт создан

Content-Type: `application/json`

```json
{
  "message": "Sandbox invoice created for subscription",
  "invoice": {
    "id": 42,
    "amount": "15000.00",
    "phone_number": "87001234567",
    "description": null,
    "external_order_id": null,
    "status": "processing",
    "client_name": "Иван Иванов",
    "internal_comment": null,
    "buyer_bin": null,
    "is_sandbox": false,
    "is_imported": false,
    "total_refunded": "0.00",
    "is_fully_refunded": false,
    "kaspi_invoice_id": "13234689513",
    "kaspi_qr_link": "https://kaspi.kz/qr/pay?tranId=QR13234689513",
    "error_message": null,
    "error_code": "idempotency_conflict",
    "items": [
      {
        "id": 1,
        "invoice_id": 42,
        "catalog_item_id": null,
        "name": "Coffee Latte",
        "price": "1800.00",
        "count": 2,
        "unit_id": 1,
        "discount": null,
        "barcode": null,
        "ntin": null,
        "gtin": null
      }
    ],
    "subtotal": "",
    "discount_sum": "",
    "discount_percentage": "",
    "paid_at": null,
    "created_at": "2026-01-10T12:00:00+00:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| invoice | object | Полный объект счёта (GET /invoices/{id}). Даты UTC +00:00. |
| invoice.id | integer |  |
| invoice.amount | string |  |
| invoice.phone_number | string |  |
| invoice.description | string \| null |  |
| invoice.external_order_id | string \| null |  |
| invoice.status | string | Статус счёта. Статуса refunded НЕ существует — полный возврат оставляет paid + is_fully_refunded=true. |
| invoice.client_name | string \| null |  |
| invoice.internal_comment | string \| null | Внутренняя заметка мерчанта (правится через PATCH /invoices/{id}). |
| invoice.buyer_bin | string \| null | ИИН/БИН покупателя, если был передан при создании. |
| invoice.is_sandbox | boolean |  |
| invoice.is_imported | boolean | `true` — продажа подтянута из истории Kaspi (проведена в приложении Kaspi Pay мимо ApiPay). Та же ось, что у фильтра `origin` в `GET /invoices`. |
| invoice.total_refunded | string |  |
| invoice.is_fully_refunded | boolean |  |
| invoice.kaspi_invoice_id | string \| null |  |
| invoice.kaspi_qr_link | string \| null | QR-ссылка Kaspi на этот счёт — нарисуйте из неё QR-код или откройте на телефоне покупателя. Вычисляется из kaspi_invoice_id, не хранится. `null`, пока Kaspi не присвоил идентификатор (статус `processing`), и всегда `null` в песочнице. Не путать с `qr_token_url` — то отдельный механизм QR-token счетов. |
| invoice.error_message | string \| null |  |
| invoice.error_code | string | Стабильный snake_case-код причины ошибки. Появляется в бизнес-ошибках API
(`error`/`error_code`) и в объектах `invoice.*`/`refund.*` вебхуков (additive,
только если non-null). Каталог значений — единый источник правды.

⚠️ Исторические значения, снятые из перечня, но встречающиеся в старых записях:
`cashbox_disabled` — не возвращается с 09.2026, кассовые операции включены
безусловно; `catalog_delivery_incomplete` — не выставляется с 03.10.2026, но остаётся
на старых строках каталога (`GET /catalog/errors`), чинится `PATCH /catalog/{id}`.
 |
| invoice.items | object[] |  |
| invoice.items.id | integer |  |
| invoice.items.invoice_id | integer |  |
| invoice.items.catalog_item_id | integer \| null | null если товар удалён. |
| invoice.items.name | string |  |
| invoice.items.price | string |  |
| invoice.items.count | integer |  |
| invoice.items.unit_id | integer \| null |  |
| invoice.items.discount | string \| null |  |
| invoice.items.barcode | string \| null | Нацкаталог-поля позиции (если были в корзине). |
| invoice.items.ntin | string \| null |  |
| invoice.items.gtin | string \| null |  |
| invoice.subtotal | string | Только при наличии скидки. |
| invoice.discount_sum | string | Только при наличии скидки. |
| invoice.discount_percentage | string | Только если передан. |
| invoice.paid_at | string \| null |  |
| invoice.created_at | string |  |

**Errors:**
- `400` — subscription_not_active / sandbox_invoice_limit / organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — not_sandbox — только для sandbox-подписок
- `404` — Подписка не найдена
- `422` — cart_unavailable — позиция корзины непригодна к продаже или снимается
- `429` — rate_limited — не прошёл кулдаун 30с

---

### POST /subscriptions/{subscription}/start-simulation

**Только sandbox.** Автогенерация sandbox-счетов с интервалом. Макс. 3 одновременных симуляции на организацию.

**Request:**
```json
{
  "interval_minutes": 1,
  "max_invoices": 5
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| interval_minutes | integer | Yes |  |
| max_invoices | integer | No |  |

**Response 200:** Симуляция запущена

Content-Type: `application/json`

```json
{
  "message": "Simulation started",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `400` — subscription_not_active / simulation_limit / organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — not_sandbox
- `404` — Подписка не найдена
- `409` — simulation_already_active

---

### POST /subscriptions/{subscription}/stop-simulation

**Только sandbox.** Останавливает авто-симуляцию.

**Response 200:** Симуляция остановлена

Content-Type: `application/json`

```json
{
  "message": "Simulation stopped",
  "subscription": {
    "id": 10,
    "subscriber_name": "Иван Иванов",
    "phone_number": "87001234567",
    "external_subscriber_id": "CLIENT-001",
    "buyer_bin": null,
    "amount": "5000.00",
    "cart_items": null,
    "description": null,
    "billing_period": "monthly",
    "billing_period_label": "Ежемесячно",
    "billing_day": 1,
    "billing_day_from_end": null,
    "billing_day_label": null,
    "billing_time": "13:00",
    "total_cycles": null,
    "cycles_paid": 0,
    "status": "active",
    "status_label": "Активна",
    "status_color": "green",
    "started_at": null,
    "next_billing_at": null,
    "next_billing_in_days": null,
    "next_billing_label": "через 3 дня",
    "paused_at": null,
    "cancelled_at": null,
    "failed_attempts": 0,
    "max_retry_attempts": 3,
    "bill_until_paid": false,
    "retry_interval_hours": 24,
    "grace_period_days": 3,
    "in_grace_period": false,
    "is_sandbox": false,
    "metadata": null,
    "created_at": "2026-01-10T12:00:00+00:00",
    "updated_at": "2026-01-01T12:00:00+05:00"
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| message | string |  |
| subscription | object | Подписка. Даты UTC +00:00. |
| subscription.id | integer |  |
| subscription.subscriber_name | string \| null |  |
| subscription.phone_number | string |  |
| subscription.external_subscriber_id | string \| null |  |
| subscription.buyer_bin | string \| null | ИИН/БИН покупателя; уходит в каждый счёт подписки. |
| subscription.amount | string |  |
| subscription.cart_items | object[] \| null |  |
| subscription.description | string \| null |  |
| subscription.billing_period | string |  |
| subscription.billing_period_label | string |  |
| subscription.billing_day | integer \| null |  |
| subscription.billing_day_from_end | integer \| null | 0 — последний день месяца, 1 — предпоследний. |
| subscription.billing_day_label | string \| null |  |
| subscription.billing_time | string \| null | Время списания по Алматы. |
| subscription.total_cycles | integer \| null | Сколько оплат запланировано всего. Пусто — бессрочно. |
| subscription.cycles_paid | integer | Сколько оплат уже получено. |
| subscription.status | string |  |
| subscription.status_label | string |  |
| subscription.status_color | string |  |
| subscription.started_at | string \| null |  |
| subscription.next_billing_at | string \| null |  |
| subscription.next_billing_in_days | integer \| null | Дней до списания по календарю Алматы. Отрицательное — просрочено. |
| subscription.next_billing_label | string \| null |  |
| subscription.paused_at | string \| null |  |
| subscription.cancelled_at | string \| null |  |
| subscription.failed_attempts | integer |  |
| subscription.max_retry_attempts | integer |  |
| subscription.bill_until_paid | boolean | Режим «выставлять, пока не оплатят». `false` у всех подписок, где режим не включали. |
| subscription.retry_interval_hours | integer |  |
| subscription.grace_period_days | integer |  |
| subscription.in_grace_period | boolean |  |
| subscription.is_sandbox | boolean |  |
| subscription.metadata | object \| null |  |
| subscription.created_at | string |  |
| subscription.updated_at | string |  |

**Errors:**
- `400` — simulation_not_active / organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — not_sandbox
- `404` — Подписка не найдена

---

### GET /cashbox/summary

Кассовая сводка по наличным за календарный день (зона Asia/Almaty). Данные
читаются с кассы Kaspi и кэшируются примерно на 60 секунд. Все суммы — строки
`"N.NN"` в тенге. Флаг `available_cashbox_actions=false` означает «Kaspi запретил операции
на кассе» — на чтении не роняется, отдаётся в данных, чтобы UI выключил
тумблеры/закрытие.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| date | string | — | `Y-m-d` или `Y-m-d H:i` (Almaty). По умолчанию — сегодня. Будущая дата → 422. |
| kaspi_connection_id | integer | — | Касса; по умолчанию primary-коннекшн организации. |

**Response 200:** Сводка по наличным

Content-Type: `application/json`

```json
{
  "date": "2026-08-10",
  "current_cash_balance": "12500.00",
  "replenishment_sum": "0.00",
  "withdrawal_sum": "0.00",
  "sale_cash_amt": "8900.00",
  "sale_return_cash_amt": "0.00",
  "cash_amount_on_opening": "3600.00",
  "sale_cash_cnt": 12,
  "sale_return_cash_cnt": 0,
  "auto_withdrawal": false,
  "available_cashbox_actions": true
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| date | string |  |
| current_cash_balance | string \| null |  |
| replenishment_sum | string \| null |  |
| withdrawal_sum | string \| null |  |
| sale_cash_amt | string \| null |  |
| sale_return_cash_amt | string \| null |  |
| cash_amount_on_opening | string \| null |  |
| sale_cash_cnt | integer \| null |  |
| sale_return_cash_cnt | integer \| null |  |
| auto_withdrawal | boolean \| null |  |
| available_cashbox_actions | boolean \| null | false = Kaspi запретил кассовые операции на этой кассе (UI выключает тумблеры/закрытие). |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive
- `409` — kaspi_session_not_configured / rfo_missing
- `422` — Ошибка валидации
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_unavailable — касса Kaspi временно недоступна

---

### GET /cashbox/reconciliation

Сверка по одной смене. Показывает ОБЕ цифры рядом — нашу выручку по счетам (`ours`:
продажи, возвраты и их разность) и цифру кассы Kaspi (`kaspi`) — и структурные причины, по которым
их **нельзя приравнять** (`discrepancies`). ⛔ Это НЕ доказательство равенства: касса
Kaspi и счета ApiPay — разные леджеры (Kaspi видит в том числе наличные и офлайн-продажи,
а наша половина — оплаты по счетам, которые числятся в ApiPay), а итог смены приходит единой суммой,
где эти части не выделены. Поэтому разницу мы не вычисляем: в ответе только обе цифры и
причины расхождения. Активной сессии кассира ручка не требует — она работает по смене,
уже полученной через `GET /cashbox/shifts`. Смены, которую ещё не листали, для сверки
не существует.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| shift_id | integer | — | kaspi_shift_id из GET /cashbox/shifts. |
| kaspi_connection_id | integer | — |  |

**Response 200:** Результат сверки

Content-Type: `application/json`

```json
{
  "mode": "shift",
  "is_sandbox": false,
  "window": {
    "from": "2026-01-01T12:00:00+05:00",
    "to": "2026-01-01T12:00:00+05:00",
    "timezone": "Asia/Almaty",
    "source": "shift"
  },
  "ours": {
    "count": 0,
    "sales": {
      "count": 0,
      "amount": "89000.00",
      "refunded_later": ""
    },
    "refunds": {
      "count": 0,
      "amount": ""
    },
    "net_amount": "89000.00",
    "coverage": {
      "invoices_without_connection": 0
    },
    "period": {
      "from": null,
      "to": null,
      "field": "paid_at"
    }
  },
  "kaspi": {
    "available": true,
    "source": "shift",
    "shift_id": 0,
    "shift_number": 0,
    "total_income": null,
    "total_income_raw": null,
    "transactions_count": null,
    "is_current": null,
    "non_cash_amount": null,
    "snapshot": {
      "synced_at": null,
      "stale": false
    }
  },
  "discrepancies": [
    {
      "code": "shift_not_calendar_day",
      "source": "kaspi",
      "message": "",
      "amount": null,
      "count": 0
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| mode | string |  |
| is_sandbox | boolean |  |
| window | object |  |
| window.from | string |  |
| window.to | string |  |
| window.timezone | string |  |
| window.source | string |  |
| ours | object | НАША половина сверки — итоги по счетам за окно. Суммы — строки `"N.NN"`.
Окно режется по `paid_at`, поэтому неоплаченных групп (отменённые, просроченные,
в ожидании) здесь нет — у таких счетов даты оплаты не существует.
 |
| ours.count | integer |  |
| ours.sales | object | Принятые деньги: paid + partially_refunded. |
| ours.sales.count | integer |  |
| ours.sales.amount | string |  |
| ours.sales.refunded_later | string | Сколько из этих счетов вернули когда-либо (объясняет разницу, не прячет). |
| ours.refunds | object | Возвраты, СОВЕРШЁННЫЕ в этом окне (по времени операции). Без connection-фильтра — по всей организации. |
| ours.refunds.count | integer |  |
| ours.refunds.amount | string |  |
| ours.net_amount | string | sales − refunds — «итого» из терминала. |
| ours.coverage | object |  |
| ours.coverage.invoices_without_connection | integer | Строк окна с пустым kaspi_connection_id (выпадают из sales при фильтре по кассе). |
| ours.period | object |  |
| ours.period.from | string \| null |  |
| ours.period.to | string \| null |  |
| ours.period.field | string |  |
| kaspi | object | Итог смены по данным кассы Kaspi — единая сумма, продажи наличными и продажи мимо ApiPay в ней не выделены. |
| kaspi.available | boolean |  |
| kaspi.source | string |  |
| kaspi.shift_id | integer |  |
| kaspi.shift_number | integer |  |
| kaspi.total_income | string \| null | Итог смены. |
| kaspi.total_income_raw | string \| null |  |
| kaspi.transactions_count | integer \| null |  |
| kaspi.is_current | boolean \| null |  |
| kaspi.non_cash_amount | string \| null | Всегда null: итог смены не разделён на наличную и безналичную части. |
| kaspi.snapshot | object |  |
| kaspi.snapshot.synced_at | string \| null |  |
| kaspi.snapshot.stale | boolean | Данные кассы получены больше 15 минут назад — обновите список смен. |
| discrepancies | object[] | Структурные причины, по которым цифры нельзя приравнять (не дефекты). |
| discrepancies.code | string |  |
| discrepancies.source | string |  |
| discrepancies.message | string |  |
| discrepancies.amount | string \| null | Всегда null — причина неквантифицируема из одной суммы Kaspi. |
| discrepancies.count | integer | Только у invoices_without_connection. |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive
- `404` — cashbox_shift_not_found — смена с таким id недоступна (не листали GET /cashbox/shifts). Организация без кассы Kaspi (ОФД) приходит сюда же: смен ей не отдаёт и сам листинг
- `422` — Ошибка валидации
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_unavailable

---

### GET /cashbox/shifts

Смены за окно `[date_from..date_to]` (глубина ≤ 31 дня). Данные читаются с кассы
Kaspi и кэшируются примерно на 60 секунд. Суммы — строки `"N.NN"`;
`total_income_raw` — исходное форматирование Kaspi (`"89 000 ₸"`), только для показа.


**Query parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| date_from | string | — | `Y-m-d`/`Y-m-d H:i` (Almaty). |
| date_to | string | — | ≥ date_from; окно ≤ 31 дня, иначе 422. |
| kaspi_connection_id | integer | — |  |

**Response 200:** Список смен

Content-Type: `application/json`

```json
{
  "auto_close_shift": false,
  "shifts": [
    {
      "id": 5012,
      "shift_number": 106,
      "start_date": "2026-08-10",
      "is_current": true,
      "total_income": "89000.00",
      "total_income_raw": "89 000 ₸",
      "transactions_count": 12
    }
  ]
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| auto_close_shift | boolean |  |
| shifts | object[] |  |
| shifts.id | integer | kaspi_shift_id |
| shifts.shift_number | integer |  |
| shifts.start_date | string \| null |  |
| shifts.is_current | boolean |  |
| shifts.total_income | string \| null | Нормализованная сумма `"N.NN"`. |
| shifts.total_income_raw | string \| null | Исходное форматирование Kaspi — только для показа. |
| shifts.transactions_count | integer \| null |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive
- `409` — kaspi_session_not_configured / cashbox_kkm_unknown
- `422` — Ошибка валидации
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_unavailable

---

### POST /cashbox/shifts/close

Ставит закрытие смены в очередь и сразу отвечает `202` со `status: pending`. Итог —
через `GET /cashbox/operations/{id}` (поле `poll_url` в ответе) или вебхук
`cashbox.shift_closed` / `cashbox.shift_close_failed`.

**Идемпотентность:** `client_operation_id` уникален на организацию — повтор с тем же
ключом даёт `409 cashbox_duplicate_operation` (с `operation_id`/`status` уже принятой
операции). ⛔ Ключ **не освобождается даже у `failed`**: повторное закрытие безопасно,
но повтор ТЕМ ЖЕ ключом — дубль; повторяйте
закрытие **новым** `client_operation_id`. «Уже закрыта» трактуется как успех
(`completed`) — целевое состояние достигнуто.


**Request:**
```json
{
  "client_operation_id": "",
  "shift_number": 0,
  "kaspi_connection_id": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| client_operation_id | string | Yes | Ключ идемпотентности (уникален на организацию). |
| shift_number | integer | Yes | Номер смены Kaspi для закрытия. |
| kaspi_connection_id | integer | No |  |

**Response 202:** Закрытие принято в обработку

Content-Type: `application/json`

```json
{
  "id": 0,
  "status": "pending",
  "client_operation_id": "",
  "poll_url": ""
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string |  |
| client_operation_id | string |  |
| poll_url | string | GET-адрес статуса операции (…/cashbox/operations/{id}). |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive
- `409` — kaspi_session_not_configured / cashbox_kkm_unknown / cashbox_duplicate_operation
- `422` — Ошибка валидации
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_unavailable — касса Kaspi временно недоступна; операция не создана, повторите с тем же client_operation_id

---

### GET /cashbox/operations/{id}

Читает данные ApiPay, к кассе Kaspi не обращается: отдельный лимит кассовых
запросов не расходует и остаётся доступной при неактивном тарифе — уже принятую
операцию всегда можно довести до терминального статуса. Чужой `id` → `404`
(scope по организации, non-enumeration). `resolution.safe_to_retry=true` только при
`status=failed` — повторяйте закрытие новым `client_operation_id`.


**Response 200:** Статус операции

Content-Type: `application/json`

```json
{
  "id": 0,
  "status": "pending",
  "operation_type": "close_shift",
  "shift_number": null,
  "error_code": null,
  "error_message": null,
  "resolution": {
    "safe_to_retry": false
  }
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| id | integer |  |
| status | string |  |
| operation_type | string |  |
| shift_number | integer \| null |  |
| error_code | string \| null | Заполнен при failed (слаг cashbox_*). |
| error_message | string \| null |  |
| resolution | object |  |
| resolution.safe_to_retry | boolean | true только при status=failed — повторяйте НОВЫМ client_operation_id. |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `404` — cashbox_operation_not_found (в т.ч. чужой id)

---

### GET /cashbox/shifts/{shift}/report

Возвращает временную подписанную ссылку (`url`) на PDF-отчёт по смене и её срок
(`expires_at`, TTL ~15 мин). Файл скачивается по этой ссылке напрямую, ключ API
для скачивания не нужен.

⚠️ Ссылка сама по себе даёт доступ к отчёту: пока не истёк `expires_at`, файл
скачает любой, кто её получил. Не публикуйте её, не кладите в логи и не передавайте
по открытым каналам — при необходимости запросите новую.


**Response 200:** Подписанная ссылка на отчёт

Content-Type: `application/json`

```json
{
  "url": "",
  "expires_at": "2026-01-01T12:00:00+05:00"
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| url | string |  |
| expires_at | string |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive
- `409` — kaspi_session_not_configured
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_report_unavailable — не удалось получить отчёт

---

### GET /cashbox/settings

Состояние обоих тумблеров. Значения tri-state: `null` = значение неизвестно
(`null` — это НЕ «выключено»).

Обычно ответ отдаётся из сохранённого состояния. Если по тумблеру состояния ещё
нет, оно один раз дочитывается с кассы и сохраняется — дальше запрос снова
бесплатный. Дочитывание не переключает тумблер и ничего не меняет на кассе; если
оно не удалось (касса недоступна, у точки нет кассира или номера кассы), ответ
остаётся `200` со значением `null`.


**Response 200:** Состояние тумблеров

Content-Type: `application/json`

```json
{
  "auto_close_shift": null,
  "auto_withdrawal": null,
  "kaspi_connection_id": null
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| auto_close_shift | boolean \| null |  |
| auto_withdrawal | boolean \| null |  |
| kaspi_connection_id | integer \| null |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — tariff_inactive
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)

---

### PUT /cashbox/settings/auto-close

Синхронное переключение. Идемпотентно: если ЖИВОЕ значение в Kaspi уже равно
запрошенному — `changed:false` без обращения к кассе.

⛔ Только ключ, выпущенный ВЛАДЕЛЬЦЕМ организации: иначе
`403 cashbox_settings_owner_key_required`. Чтение (`GET /cashbox/settings`)
доступно любому ключу организации.


**Request:**
```json
{
  "enabled": false,
  "kaspi_connection_id": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| enabled | boolean | Yes |  |
| kaspi_connection_id | integer | No |  |

**Response 200:** Результат переключения

Content-Type: `application/json`

```json
{
  "changed": false,
  "new_value": false
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| changed | boolean | false = живое значение в Kaspi уже равнялось запрошенному (no-op). |
| new_value | boolean |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — cashbox_settings_owner_key_required / tariff_inactive
- `409` — kaspi_session_not_configured / rfo_missing
- `422` — Ошибка валидации
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_toggle_in_progress — переключение уже выполняется, повторите позже / cashbox_toggle_unavailable — текущее значение на кассе проверить не удалось, переключение не выполнено

---

### PUT /cashbox/settings/auto-withdrawal

Автоизъятие наличных после закрытия смены. Поведение идентично `auto-close`
(синхронный write, лок, идемпотентность по живому значению, ключ владельца
организации).


**Request:**
```json
{
  "enabled": false,
  "kaspi_connection_id": null
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| enabled | boolean | Yes |  |
| kaspi_connection_id | integer | No |  |

**Response 200:** Результат переключения

Content-Type: `application/json`

```json
{
  "changed": false,
  "new_value": false
}
```

#### Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| changed | boolean | false = живое значение в Kaspi уже равнялось запрошенному (no-op). |
| new_value | boolean |  |

**Errors:**
- `400` — organization_required
- `401` — X-API-Key отсутствует или невалиден
- `403` — cashbox_settings_owner_key_required / tariff_inactive
- `409` — kaspi_session_not_configured / rfo_missing
- `422` — Ошибка валидации
- `429` — Превышен отдельный лимит кассовых запросов (30/мин)
- `503` — cashbox_toggle_in_progress — переключение уже выполняется / cashbox_toggle_unavailable — текущее значение на кассе проверить не удалось

---

## Webhooks

Webhooks are configured in the ApiPay.kz dashboard (Settings > Connection).
When creating a webhook, you receive a secret for signature verification (HMAC-SHA256).

### Events

- `invoice.status_changed`
- `invoice.qr_scanned`
- `invoice.refunded`
- `receipt.issued`
- `receipt.failed`
- `qr_refund.identified`
- `qr_refund.completed`
- `qr_refund.expired`
- `qr_refund.failed`
- `qr_refund.execution_uncertain`
- `subscription.payment_succeeded`
- `subscription.payment_failed`
- `subscription.grace_period_started`
- `subscription.expired`
- `subscription.created`
- `subscription.paused`
- `subscription.resumed`
- `subscription.period_skipped`
- `subscription.cancelled`
- `cashbox.shift_closed`
- `cashbox.shift_close_failed`
- `webhook.test`

### Payloads

#### `invoice.status_changed`

```json
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "15000.00",
    "subtotal": "16500.00",
    "discount_sum": "1500.00",
    "discount_percentage": "10",
    "status": "paid",
    "description": "Оплата заказа",
    "kaspi_invoice_id": "13234689513",
    "client_name": "Иван Иванов",
    "client_phone": "87071234567",
    "is_sandbox": false,
    "kaspi_source_type": "GOLD",
    "kaspi_sale_type": "Remote",
    "paid_at": "2026-02-12T14:35:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:35:01+00:00"
}
```

#### Счёт не обработан (status: error)

```json
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 43,
    "external_order_id": "order_124",
    "amount": "15000.00",
    "status": "error",
    "description": "Оплата заказа",
    "kaspi_invoice_id": null,
    "client_name": null,
    "client_phone": "87071234567",
    "is_sandbox": false,
    "errored_at": "2026-02-12T14:40:00+00:00",
    "error_message": "Этот номер телефона не зарегистрирован в Kaspi. Укажите номер с установленным приложением Kaspi.",
    "error_code": "client_not_found"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:40:01+00:00"
}
```

#### Счёт истёк (status: expired)

```json
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 46,
    "external_order_id": "order_125",
    "amount": "15000.00",
    "status": "expired",
    "description": "Оплата заказа",
    "kaspi_invoice_id": "13234689515",
    "client_name": "Иван Иванов",
    "client_phone": "87071234567",
    "is_sandbox": false,
    "expired_at": "2026-02-13T14:35:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-13T14:35:01+00:00"
}
```

#### QR-счёт отменён клиентом (status: cancelled)

```json
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 44,
    "external_order_id": null,
    "amount": "15000.00",
    "status": "cancelled",
    "description": "QR на кассе",
    "kaspi_invoice_id": "13234689514",
    "client_name": null,
    "client_phone": null,
    "is_sandbox": false,
    "cancelled_at": "2026-02-12T14:45:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:45:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — invoice.status_changed. |
| invoice.id | integer | Внутренний ID счёта в ApiPay. |
| invoice.external_order_id | string | null | Ваш внешний идентификатор заказа, переданный при создании счёта. |
| invoice.amount | string | Сумма счёта. |
| invoice.subtotal | string | null | Сумма до применения скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе). |
| invoice.discount_sum | string | null | Сумма скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе). |
| invoice.discount_percentage | string | null | Процент скидки. Только для счетов с корзиной/скидкой. |
| invoice.status | string | Статус счёта: pending / paid / cancelled / expired / error / partially_refunded. Статуса refunded не существует — полный возврат оставляет paid + is_fully_refunded=true. |
| invoice.kaspi_invoice_id | string | null | ID счёта в Kaspi. Появляется уже при pending (когда счёт создан в Kaspi); null — пока счёт не дошёл до Kaspi. |
| invoice.client_phone | string | Номер телефона клиента. |
| invoice.kaspi_source_type | string | null | Источник средств клиента: GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее». |
| invoice.kaspi_sale_type | string | null | Способ приёма счёта: Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее». |
| invoice.paid_at | string | null | Время оплаты счёта (ISO 8601). Поле отсутствует во всех статусах, кроме paid (а не null до оплаты). |
| invoice.error_message | string | null | Человекочитаемая причина. При status=error присутствует всегда; при status=cancelled — только если заполнена (обычно отсутствует при отмене клиентом или через API). В статусах paid/pending/expired поле отсутствует. |
| invoice.error_code | string | null | Стабильный snake_case-код из каталога (раздел "Коды ошибок"). Присутствует только если не null и только при status=error/cancelled. Стройте switch-логику по нему, а не по тексту. |
| invoice.cancelled_at | string | null | Время перехода в cancelled (ISO 8601). Присутствует только при соответствующем статусе. |
| invoice.expired_at | string | null | Время перехода в expired (ISO 8601). Присутствует только при соответствующем статусе. |
| invoice.errored_at | string | null | Время перехода в error (ISO 8601). Присутствует только при соответствующем статусе. |
| source | string | null | Название API-ключа, через который создан счёт. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `invoice.qr_scanned`

```json
{
  "event": "invoice.qr_scanned",
  "invoice": {
    "id": 108565,
    "external_order_id": "order-123",
    "amount": "1500.00",
    "status": "pending",
    "qr_substate": "scanned",
    "description": "Оплата заказа",
    "kaspi_invoice_id": "15977100656",
    "client_name": null,
    "client_phone": null,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-06-14T14:37:00+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — invoice.qr_scanned. |
| invoice.id | integer | Внутренний ID счёта в ApiPay. |
| invoice.external_order_id | string | null | Ваш внешний идентификатор заказа, переданный при создании счёта. |
| invoice.amount | string | Сумма счёта в тенге. |
| invoice.status | string | Всегда pending — qr_scanned не меняет статус, а сообщает о суб-состоянии «на экране оплаты». |
| invoice.qr_substate | string | Маркер суб-состояния QR — scanned (клиент отсканировал QR и на экране оплаты). |
| invoice.description | string | null | Описание счёта. |
| invoice.kaspi_invoice_id | string | null | ID счёта в Kaspi. |
| invoice.client_name | string | null | Имя клиента (для QR-счёта обычно null). |
| invoice.client_phone | string | null | Телефон клиента (для QR-счёта обычно null). |
| invoice.is_sandbox | boolean | Признак sandbox-счёта. |
| source | string | null | Название API-ключа, через который создан счёт. |
| timestamp | string | Время отправки события (ISO 8601, UTC). |

#### `invoice.refunded`

```json
{
  "event": "invoice.refunded",
  "refund": {
    "id": 5,
    "amount": "2000.00",
    "status": "completed",
    "kaspi_refund_id": "1126827352",
    "reason": "Возврат товара",
    "created_at": "2026-02-12T10:00:00+00:00",
    "items": [{"catalog_item_id": 12, "name": "Кофе", "price": "1000.00", "count": 2, "amount": "2000.00"}]
  },
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "5000.00",
    "subtotal": "5500.00",
    "discount_sum": "500.00",
    "total_refunded": "2000.00",
    "available_for_refund": 3000,
    "is_fully_refunded": false,
    "is_sandbox": false,
    "status": "paid",
    "kaspi_invoice_id": "13234689513"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T10:00:01+00:00"
}
```

#### Возврат не удался (refund.status: failed)

```json
{
  "event": "invoice.refunded",
  "refund": {
    "id": 6,
    "amount": "2000.00",
    "status": "failed",
    "kaspi_refund_id": null,
    "reason": "Возврат товара",
    "created_at": "2026-02-12T10:00:00+00:00",
    "error_code": "refund_window_expired"
  },
  "invoice": {
    "id": 42, "external_order_id": "order_123", "amount": "5000.00",
    "total_refunded": "0.00", "available_for_refund": "5000.00",
    "is_fully_refunded": false, "is_sandbox": false,
    "status": "paid", "kaspi_invoice_id": "13234689513"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T10:00:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — invoice.refunded. |
| refund.id | integer | ID возврата. |
| refund.amount | string | Сумма возврата. |
| refund.status | string | pending / processing / completed / failed. Вебхук приходит на completed И на failed. |
| refund.kaspi_refund_id | string | null | ID возврата в Kaspi; null при неудаче. |
| refund.reason | string | null | Причина возврата. |
| refund.created_at | string | Время создания возврата (ISO 8601). |
| refund.error_code | string | null | Только при status=failed. Например refund_window_expired — истёк срок возврата (~14 дней). Поля error_message в вебхуке нет by design — текст смотрите в GET /invoices/{id}/refunds или резолвите код по каталогу. |
| refund.items | array | null | Позиции возврата (только для позиционных возвратов): catalog_item_id, name, price, count, amount. |
| invoice.id | integer | Внутренний ID счёта в ApiPay. |
| invoice.external_order_id | string | null | Ваш внешний идентификатор заказа. |
| invoice.amount | string | Сумма счёта. |
| invoice.subtotal | string | Сумма счёта до применения скидки. |
| invoice.discount_sum | string | Сумма скидки по счёту. |
| invoice.total_refunded | string | Суммарно возвращено по счёту на текущий момент. |
| invoice.available_for_refund | number | Сумма, ещё доступная для возврата. Приходит числом (float), в отличие от amount и total_refunded, которые передаются строками. |
| invoice.is_fully_refunded | boolean | true, если счёт возвращён полностью. |
| invoice.is_sandbox | boolean | Счёт создан в sandbox-режиме. |
| invoice.status | string | Статус счёта после возврата. Полный возврат статус НЕ меняет (остаётся paid — или partially_refunded, если ранее был частичный) + is_fully_refunded=true; первый частичный переводит в partially_refunded (и дополнительно приходит invoice.status_changed). |
| invoice.kaspi_invoice_id | string | null | ID счёта в Kaspi. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.payment_succeeded`

```json
{
  "event": "subscription.payment_succeeded",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "invoice_id": 200,
  "amount": "5000.00",
  "paid_at": "2026-02-01T12:00:00+00:00",
  "source": "My API Key",
  "timestamp": "2026-02-01T12:00:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.payment_succeeded. |
| subscription.id | integer | ID подписки. |
| subscription.external_subscriber_id | string | null | Ваш внешний идентификатор подписчика. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.subscriber_name | string | null | Имя подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (например, active). |
| subscription.next_billing_at | string | null | Дата следующего списания (ISO 8601). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| invoice_id | integer | ID счёта, по которому прошёл платёж. |
| amount | string | Сумма успешного платежа. |
| paid_at | string | Время оплаты (ISO 8601). |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.payment_failed`

```json
{
  "event": "subscription.payment_failed",
  "subscription": {
    "id": 10,
    "phone_number": "87071234567",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "failed_attempts": 2,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "invoice_id": 201,
  "amount": "5000.00",
  "reason": "Invoice expired",
  "attempt_number": 2,
  "error_code": null,
  "source": "My API Key",
  "timestamp": "2026-02-02T12:00:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.payment_failed. |
| subscription.id | integer | ID подписки. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (например, active). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| invoice_id | integer | ID счёта, по которому не прошёл платёж. |
| amount | string | Сумма неуспешного платежа. |
| reason | string | null | Причина неуспеха: "Invoice expired", "Invoice cancelled" или "Invoice error: <код>" (код — в error_code). |
| attempt_number | integer | Номер текущей попытки списания. |
| error_code | string | null | Код ошибки, если счёт ушёл в статус error (например, client_not_found). |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.grace_period_started`

```json
{
  "event": "subscription.grace_period_started",
  "subscription": {
    "id": 10,
    "phone_number": "87071234567",
    "amount": "5000.00",
    "status": "active",
    "failed_attempts": 3,
    "in_grace_period": true,
    "is_sandbox": false
  },
  "grace_period_days": 3,
  "expires_at": "2026-02-05T12:00:00+00:00",
  "source": "My API Key",
  "timestamp": "2026-02-02T12:00:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.grace_period_started. |
| subscription.id | integer | ID подписки. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.status | string | Статус подписки (например, active). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде (здесь true). |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| grace_period_days | integer | Длительность льготного периода в днях. |
| expires_at | string | Когда истекает льготный период (ISO 8601). |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.expired`

```json
{
  "event": "subscription.expired",
  "subscription": {
    "id": 10,
    "phone_number": "87071234567",
    "amount": "5000.00",
    "status": "expired",
    "next_billing_at": null,
    "failed_attempts": 3,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-05T12:00:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.expired. |
| subscription.id | integer | ID подписки. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.status | string | Статус подписки (здесь expired). |
| subscription.next_billing_at | string | null | Дата следующего списания (null для истёкшей подписки). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.created`

```json
{
  "event": "subscription.created",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-01T12:00:01+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.created. |
| subscription.id | integer | ID подписки. |
| subscription.external_subscriber_id | string | null | Ваш внешний идентификатор подписчика. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.subscriber_name | string | null | Имя подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (здесь active). |
| subscription.next_billing_at | string | null | Дата следующего списания (ISO 8601). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.paused`

```json
{
  "event": "subscription.paused",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "paused",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-10T09:00:00+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.paused. |
| subscription.id | integer | ID подписки. |
| subscription.external_subscriber_id | string | null | Ваш внешний идентификатор подписчика. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.subscriber_name | string | null | Имя подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (здесь paused). |
| subscription.next_billing_at | string | null | Дата следующего списания (ISO 8601). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.resumed`

```json
{
  "event": "subscription.resumed",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-03-15T09:30:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-15T09:30:00+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.resumed. |
| subscription.id | integer | ID подписки. |
| subscription.external_subscriber_id | string | null | Ваш внешний идентификатор подписчика. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.subscriber_name | string | null | Имя подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (здесь active). |
| subscription.next_billing_at | string | null | Дата следующего списания (ISO 8601), пересчитанная от момента возобновления. |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.period_skipped`

```json
{
  "event": "subscription.period_skipped",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-04-01T08:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "billing_period_start": "2026-03-01",
  "billing_period_end": "2026-03-31",
  "cancelled_invoice_id": 202,
  "source": "My API Key",
  "timestamp": "2026-03-02T10:15:00+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.period_skipped. |
| subscription.id | integer | ID подписки. |
| subscription.external_subscriber_id | string | null | Ваш внешний идентификатор подписчика. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.subscriber_name | string | null | Имя подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (здесь active). |
| subscription.next_billing_at | string | null | Когда придёт счёт следующего периода (ISO 8601). |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания; пропуск периода их сбрасывает. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде; пропуск периода льготный период снимает. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| billing_period_start | string | Первый день пропущенного периода (ГГГГ-ММ-ДД) — как у строки истории платежей. Входит в ключ дедупликации. |
| billing_period_end | string | Последний день пропущенного периода (ГГГГ-ММ-ДД). |
| cancelled_invoice_id | integer | null | ID счёта периода, отмену которого запросил пропуск; null, если счёт за период ещё не выставлялся. Итог отмены приходит invoice.status_changed. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `subscription.cancelled`

```json
{
  "event": "subscription.cancelled",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "cancelled",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-20T18:00:00+00:00"
}
```

#### Плательщик отклонил счёт (reason: payer_refused)

```json
{
  "event": "subscription.cancelled",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "cancelled",
    "next_billing_at": "2026-03-01T08:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "reason": "payer_refused",
  "invoice_id": 201,
  "source": "My API Key",
  "timestamp": "2026-02-01T09:30:00+00:00"
}
```

#### Номер не зарегистрирован в Kaspi, режим bill_until_paid (reason: payer_error)

```json
{
  "event": "subscription.cancelled",
  "subscription": {
    "id": 11,
    "external_subscriber_id": "CLIENT-002",
    "phone_number": "87071234568",
    "subscriber_name": null,
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "cancelled",
    "next_billing_at": "2026-03-01T08:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "reason": "payer_error",
  "invoice_id": 202,
  "error_code": "client_not_found",
  "source": "My API Key",
  "timestamp": "2026-02-01T08:00:05+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — subscription.cancelled. |
| subscription.id | integer | ID подписки. |
| subscription.external_subscriber_id | string | null | Ваш внешний идентификатор подписчика. |
| subscription.phone_number | string | Номер телефона подписчика. |
| subscription.subscriber_name | string | null | Имя подписчика. |
| subscription.amount | string | Сумма платежа по подписке. |
| subscription.billing_period | string | Период списания (например, monthly). |
| subscription.status | string | Статус подписки (здесь cancelled). |
| subscription.next_billing_at | string | null | Дата следующего списания (ISO 8601). НЕ обнуляется при отмене — сохраняет последнее значение; счета больше не выставляются. |
| subscription.failed_attempts | integer | Количество подряд неуспешных попыток списания. |
| subscription.in_grace_period | boolean | Находится ли подписка в льготном периоде. |
| subscription.is_sandbox | boolean | Подписка создана в sandbox-режиме. |
| reason | string | null | Причина отмены, если подписку отменил не ваш запрос: payer_refused — явный отказ плательщика в Kaspi; payer_error — у подписки с bill_until_paid: true счёт упал с error_code = client_not_found. При отмене запросом поля нет. |
| invoice_id | integer | null | ID счёта, по которому отменена подписка (при reason payer_refused и payer_error). |
| error_code | string | null | Код ошибки счёта при reason payer_error — client_not_found. |
| source | string | null | Название API-ключа. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `receipt.issued`

```json
{
  "event": "receipt.issued",
  "receipt": {
    "id": 4210,
    "client_operation_id": "pos-cash-0042",
    "payment_type": 3,
    "status": "issued",
    "fpd": "000000000000",
    "operation_id": "KKM00000000",
    "link": "https://receipt.kaspi.kz/preview/cashier?extTranId=KKM00000000",
    "shift_number": null,
    "total_price": "10.00",
    "error_code": null,
    "error_message": null
  },
  "timestamp": "2026-07-12T16:25:43+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — receipt.issued. |
| receipt.id | integer | Внутренний ID чека в ApiPay. |
| receipt.client_operation_id | string | null | Ваш ключ идемпотентности, переданный при выбивании чека. |
| receipt.payment_type | integer | Тип оплаты: 3 — наличные, 5 — POS другого банка. |
| receipt.status | string | Статус чека — issued (успешно выбит). |
| receipt.fpd | string | null | Фискальный признак документа (ФПД) от Kaspi OFD. |
| receipt.operation_id | string | null | Идентификатор операции в Kaspi. |
| receipt.link | string | null | Ссылка на чек на receipt.kaspi.kz. |
| receipt.shift_number | integer | null | Номер смены кассира. У новых чеков null — не делайте поле обязательным. |
| receipt.total_price | string | null | Сумма чека. |
| receipt.error_code | string | null | Для issued всегда null. |
| receipt.error_message | string | null | Для issued всегда null. |
| timestamp | string | Время отправки события (ISO 8601, UTC +00:00). |

#### `receipt.failed`

```json
{
  "event": "receipt.failed",
  "receipt": {
    "id": 4211,
    "client_operation_id": "pos-cash-0043",
    "payment_type": 3,
    "status": "failed",
    "fpd": null,
    "operation_id": null,
    "link": null,
    "shift_number": null,
    "total_price": "10.00",
    "error_code": "shift_closed",
    "error_message": "Смена кассира закрыта — откройте смену в приложении Kaspi Pay и повторите."
  },
  "timestamp": "2026-07-12T16:26:10+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — receipt.failed. |
| receipt.id | integer | Внутренний ID чека в ApiPay. |
| receipt.client_operation_id | string | null | Ваш ключ идемпотентности, переданный при выбивании чека. |
| receipt.payment_type | integer | Тип оплаты: 3 — наличные, 5 — POS другого банка. |
| receipt.status | string | Статус чека — failed. |
| receipt.fpd | string | null | При failed всегда null — фискальный документ не создан. |
| receipt.operation_id | string | null | При failed всегда null. |
| receipt.link | string | null | При failed всегда null. |
| receipt.shift_number | integer | null | У новых чеков null — не делайте поле обязательным. |
| receipt.total_price | string | null | Сумма чека. |
| receipt.error_code | string | null | Код причины: shift_closed (закрыта смена), item_not_fiscal (позиции нет в каталоге, в бою она не синхронизирована с Kaspi или у неё штрихкод без НТИН), receipt_vat_not_supported (позиция со ставкой НДС), rfo_missing, receipt_kaspi_error, receipt_dispatch_error. Стройте switch по нему, не по тексту. |
| receipt.error_message | string | null | Человекочитаемое пояснение ошибки. |
| timestamp | string | Время отправки события (ISO 8601, UTC +00:00). |

#### `qr_refund.identified`

```json
{
  "event": "qr_refund.identified",
  "qr_refund": {
    "id": 42,
    "status": "customer_identified",
    "client_name": "Иван И.",
    "expires_at": "2026-07-27T17:27:09+00:00",
    "is_sandbox": false
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:22:31+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — qr_refund.identified. |
| qr_refund.id | integer | ID сессии QR-возврата. |
| qr_refund.status | string | Всегда customer_identified. Статус берётся из СОБЫТИЯ, а не из живой сессии: ретрай не принесёт противоречивый payload. |
| qr_refund.client_name | string | null | Имя покупателя от Kaspi. null, пока покупатель не подтвердил. |
| qr_refund.expires_at | string | null | Срок действия ссылки (ISO 8601). |
| qr_refund.is_sandbox | boolean | Сессия создана в песочнице. |
| source | string | null | Имя API-ключа, которым создана сессия. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `qr_refund.completed`

```json
{
  "event": "qr_refund.completed",
  "qr_refund": {
    "id": 42,
    "status": "completed",
    "client_name": "Иван И.",
    "expires_at": "2026-07-27T17:27:09+00:00",
    "is_sandbox": false,
    "refunded_amount": "500.00",
    "receipt_url": "https://receipt.kaspi.kz/preview/cashier?extTranId=KKM00000000"
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:24:02+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — qr_refund.completed. |
| qr_refund.id | integer | ID сессии QR-возврата. |
| qr_refund.status | string | Всегда completed. |
| qr_refund.client_name | string | null | Имя покупателя от Kaspi. null, пока покупатель не подтвердил. |
| qr_refund.expires_at | string | null | Срок действия ссылки (ISO 8601). |
| qr_refund.is_sandbox | boolean | Сессия создана в песочнице. |
| qr_refund.refunded_amount | string | null | Возвращённая сумма. Приходит ТОЛЬКО в qr_refund.completed. |
| qr_refund.receipt_url | string | null | Ссылка на чек возврата в Kaspi. Только в qr_refund.completed. |
| source | string | null | Имя API-ключа, которым создана сессия. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `qr_refund.expired`

```json
{
  "event": "qr_refund.expired",
  "qr_refund": {
    "id": 43,
    "status": "expired",
    "client_name": null,
    "expires_at": "2026-07-27T17:20:00+00:00",
    "is_sandbox": false
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:20:05+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — qr_refund.expired. |
| qr_refund.id | integer | ID сессии QR-возврата. |
| qr_refund.status | string | Всегда expired. |
| qr_refund.client_name | string | null | Имя покупателя от Kaspi. null, пока покупатель не подтвердил. |
| qr_refund.expires_at | string | null | Срок действия ссылки (ISO 8601). |
| qr_refund.is_sandbox | boolean | Сессия создана в песочнице. |
| source | string | null | Имя API-ключа, которым создана сессия. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `qr_refund.failed`

```json
{
  "event": "qr_refund.failed",
  "qr_refund": {
    "id": 44,
    "status": "failed",
    "client_name": null,
    "expires_at": null,
    "is_sandbox": false,
    "error_code": "qr_refund_activation_failed",
    "error_message": "Безопасно начать подтверждение не удалось. Создайте новую ссылку на возврат."
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:21:14+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — qr_refund.failed. |
| qr_refund.id | integer | ID сессии QR-возврата. |
| qr_refund.status | string | Всегда failed. |
| qr_refund.client_name | string | null | Имя покупателя от Kaspi. null, пока покупатель не подтвердил. |
| qr_refund.expires_at | string | null | Конец окна скана (ISO 8601). null, если окно так и не начиналось. |
| qr_refund.is_sandbox | boolean | Сессия создана в песочнице. |
| qr_refund.error_code | string | Закреплён за событием и всегда равен qr_refund_activation_failed — читать его с живой сессии не нужно. |
| qr_refund.error_message | string | Текст для продавца, закреплён за событием: начать подтверждение не удалось, нужна новая ссылка на возврат. |
| source | string | null | Имя API-ключа, которым создана сессия. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `qr_refund.execution_uncertain`

```json
{
  "event": "qr_refund.execution_uncertain",
  "qr_refund": {
    "id": 45,
    "status": "execution_uncertain",
    "client_name": "Иван И.",
    "expires_at": "2026-07-27T17:27:09+00:00",
    "is_sandbox": false,
    "error_code": "qr_refund_execution_uncertain",
    "error_message": "Статус возврата требует ручной проверки. Не повторяйте возврат и обратитесь в поддержку."
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:24:48+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — qr_refund.execution_uncertain. |
| qr_refund.id | integer | ID сессии QR-возврата. |
| qr_refund.status | string | Всегда execution_uncertain. |
| qr_refund.client_name | string | null | Имя покупателя от Kaspi. |
| qr_refund.expires_at | string | null | Конец окна скана (ISO 8601). |
| qr_refund.is_sandbox | boolean | Сессия создана в песочнице. |
| qr_refund.error_code | string | Закреплён за событием и всегда равен qr_refund_execution_uncertain — читать его с живой сессии не нужно. |
| qr_refund.error_message | string | Текст для продавца, закреплён за событием: статус возврата требует ручной проверки, повторять возврат не нужно. |
| source | string | null | Имя API-ключа, которым создана сессия. |
| timestamp | string | Время отправки события (ISO 8601). |

#### `cashbox.shift_closed`

```json
{
  "event": "cashbox.shift_closed",
  "operation": {
    "id": 812,
    "operation_type": "close_shift",
    "status": "completed",
    "shift_number": 106,
    "error_code": null
  },
  "timestamp": "2026-08-10T16:25:43+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — cashbox.shift_closed. |
| operation.id | integer | ID кассовой операции — тот же, что вернул ответ 202 и по которому идёт поллинг GET /cashbox/operations/{id}. Дедуплицируйте по паре (event, operation.id). |
| operation.operation_type | string | Тип операции. Сейчас всегда close_shift. |
| operation.status | string | Терминальный статус операции — completed. |
| operation.shift_number | integer | null | Номер закрытой смены. |
| operation.error_code | string | null | При успехе всегда null. |
| timestamp | string | Время события в UTC. |

#### `cashbox.shift_close_failed`

```json
{
  "event": "cashbox.shift_close_failed",
  "operation": {
    "id": 813,
    "operation_type": "close_shift",
    "status": "failed",
    "shift_number": 106,
    "error_code": "cashbox_operation_failed"
  },
  "timestamp": "2026-08-10T16:26:11+00:00"
}
```

#### Поля payload

| Поле | Тип | Описание |
|------|-----|----------|
| event | string | Тип события — cashbox.shift_close_failed. |
| operation.id | integer | ID кассовой операции. Дедуплицируйте по паре (event, operation.id). |
| operation.operation_type | string | Тип операции. Сейчас всегда close_shift. |
| operation.status | string | Терминальный статус операции — failed. |
| operation.shift_number | integer | null | Номер смены, которую пытались закрыть. |
| operation.error_code | string | null | Причина отказа — слаг вида cashbox_*. Повторяйте закрытие только новым client_operation_id: прежний ключ после отказа не освобождается. |
| timestamp | string | Время события в UTC. |

### Когда приходит вебхук

Этот раздел перечисляет события, при которых ApiPay шлёт вебхук, и поясняет,
в каком статусе он приходит. Технические статусы processing/cancelling
вебхуков не порождают.

| Событие | Статус | Когда приходит |
|---------|--------|----------------|
| `invoice.status_changed` | `pending` | Счёт создан в Kaspi и ожидает оплату. Для счетов по номеру (POST /invoices) это первый вебхук после 201-ответа со status=processing. Для QR-счетов (POST /invoices/qr) pending-вебхук НЕ отправляется — статус возвращается синхронно в 201-ответе; первый вебхук по QR-счёту — оплата, отмена, истечение или ошибка. |
| `invoice.qr_scanned` | `pending` | Только для QR-счетов: клиент отсканировал QR и оказался на экране оплаты Kaspi (qr_substate=scanned). status остаётся pending — это суб-состояние, а не смена статуса. Шлётся ровно один раз; событие транзиентно — далее придёт paid (оплатил) или cancelled (свернул/закрыл приложение). Не считайте скан гарантией оплаты. |
| `invoice.status_changed` | `paid` | Счёт оплачен. Может прийти и ПОСЛЕ cancelled/expired — оплата в последний момент выигрывает гонку (см. «Переходы статусов»). |
| `invoice.status_changed` | `cancelled` | Счёт отменён: вами через API, кассиром, либо для QR — отменой со стороны клиента (свернул или закрыл приложение Kaspi, не подтвердив оплату). Создание нового QR на той же кассе старый счёт НЕ отменяет — supersede-вебхука больше нет. |
| `invoice.status_changed` | `expired` | Счёт истёк. Phone-счёт — 24 часа в Kaspi. QR-счёт — минуты, и только когда Kaspi сообщил, что ссылка больше не действует, а НЕ по локальному таймеру qr_expires_at. |
| `invoice.status_changed` | `error` | Техническая ошибка — счёт финализирован, система больше НЕ повторяет попытки по этому счёту. Причина — в error_code/error_message. Что делать — раздел «Сценарии реагирования». |
| `invoice.status_changed` | `partially_refunded` | Первый частичный возврат по счёту (дополнительно к invoice.refunded). Повторные частичные возвраты статус не меняют. Полный возврат статус НЕ меняет — счёт остаётся paid (или partially_refunded, если ранее был частичный) с is_fully_refunded=true. |
| `invoice.refunded` | `completed` | Возврат проведён. Включает возвраты, сделанные кассиром в приложении Kaspi (импортируются автоматически). |
| `invoice.refunded` | `failed` | Возврат не удался (refund.error_code). Система сама НЕ повторяет; сумма не блокируется — можно создать новый возврат. |
| `receipt.issued` | — | Фискальный чек выбит в Kaspi OFD (POST /receipts, наличные / POS другого банка). В receipt — fpd, operation_id, link; shift_number у новых чеков null. Равноправно поллингу GET /receipts/{id}. Гейт вебхуков receipt.* отдельный (по умолчанию выключен). |
| `receipt.failed` | — | Не удалось выбить фискальный чек (POST /receipts). Причина — receipt.error_code (shift_closed, item_not_fiscal, receipt_vat_not_supported, rfo_missing, receipt_kaspi_error, receipt_dispatch_error). Фискальный документ НЕ создан — повтор допустим: после failed прежний client_operation_id освобождается. |
| `subscription.created` | — | Подписка создана через POST /subscriptions. Счета по подписке выставляет система автоматически: первый счёт будет выставлен в next_billing_at (или сразу при bill_immediately). По каждому счёту приходят обычные invoice-вебхуки. |
| `subscription.payment_succeeded` | — | Очередной счёт подписки оплачен. failed_attempts сброшен, льготный период (если был) снят. |
| `subscription.payment_failed` | — | Счёт подписки истёк, отменён или ушёл в error (reason, error_code). Пересоздавать счёт не нужно, просто уведомите клиента (attempt_number, reason). При bill_until_paid: false система САМА перевыставит счёт, пока attempt_number < max_retry_attempts (по умолчанию 3), с интервалом retry_interval_hours (по умолчанию 24 ч; истёкший счёт — сразу). При bill_until_paid: true истёкший счёт перевыставляется сразу до момента следующего планового списания, а после отмены или ошибки счёта (кроме ошибки на стороне сервиса) следующий придёт в следующем периоде. У подписки с bill_until_paid: true и error_code = client_not_found это событие не приходит — вместо него subscription.cancelled с reason: payer_error. |
| `subscription.grace_period_started` | — | Все попытки исчерпаны; подписка ещё активна grace_period_days (по умолчанию 3) дней. Любая успешная оплата снимает льготный период. У подписки с bill_until_paid: true не приходит. |
| `subscription.expired` | — | Подписка истекла — счета больше не выставляются. Истёкшую после льготного периода подписку можно возобновить запросом POST /subscriptions/{id}/resume (придёт subscription.resumed); подписку, получившую все оплаты total_cycles, — нельзя: для продолжения создайте новую. |
| `subscription.paused` | — | Подписка приостановлена (POST /subscriptions/{id}/pause). Счета не выставляются. |
| `subscription.resumed` | — | Подписка возобновлена; next_billing_at пересчитан от момента возобновления, пропущенные периоды не доначисляются. |
| `subscription.period_skipped` | — | Период подписки закрыт без оплаты — в кабинете ApiPay или запросом POST /subscriptions/{id}/skip-period. Подписка остаётся активной. В корне — billing_period_start, billing_period_end и cancelled_invoice_id (счёт, отмену которого запросил пропуск, или null). Итог отмены этого счёта приходит отдельным invoice.status_changed; если покупатель всё же оплатит счёт, придёт subscription.payment_succeeded, и период станет оплаченным. |
| `subscription.cancelled` | — | Подписка отменена безвозвратно (next_billing_at сохраняет последнее значение, счета не выставляются): запросом POST /subscriptions/{id}/cancel, явным отказом плательщика в Kaspi (reason: payer_refused, invoice_id) либо, у подписки с bill_until_paid: true, счётом с error_code = client_not_found (reason: payer_error, invoice_id, error_code). |
| `webhook.test` | — | Ручной тест из ЛК (Настройки → API-ключи → Тест вебхука). Фиктивный счёт со status=test — receiver должен спокойно его игнорировать. |
| `cashbox.shift_closed` | `completed` | Кассовая смена закрыта. Приходит по операции, принятой запросом POST /cashbox/shifts/close (ответ 202). Ответ Kaspi «смена уже закрыта» тоже считается успехом: целевое состояние достигнуто. |
| `cashbox.shift_close_failed` | `failed` | Закрытие смены не удалось; причина — в operation.error_code. Смена могла остаться открытой. Автоматически повторяйте только при resolution.safe_to_retry=true в GET /cashbox/operations/{id} и только с новым client_operation_id. |

### Переходы статусов

Технический шум подавляется: дубли подряд одного статуса, технические
processing/cancelling, «протухший» pending после терминального статуса и
error после paid до вас не доходят. При этом
повторная доставка одного и того же перехода возможна (ретраи после
частичной доставки) — дедуплицируйте по (invoice.id, status) и (refund.id,
status). Отвечайте 200 OK быстро (≤5 секунд), обрабатывайте асинхронно.
Событие invoice.qr_scanned статус НЕ меняет (status остаётся pending) — это
суб-состояние, идёт мимо этой матрицы. По каждому QR-счёту терминальный
статус (paid/expired/cancelled) формируется один раз, даже если мониторинг
прерывался. Это про сам статус, а не про доставку. Доставка события
зависит от доступности вашего endpoint: если отправка приостановлена
(см. «Ретраи и circuit breaker»), сверяйте состояние через GET /invoices/{id}.

**Разрешённые переходы:**

| Из | В | Комментарий |
|----|---|-------------|
| `pending` | `paid \| cancelled \| expired \| error` | обычный жизненный цикл |
| `cancelled \| expired` | `paid` | оплата в последний момент выигрывает гонку — обработайте как «деньги получены»: отгрузите или сделайте возврат. Это не баг. |
| `error` | `pending` | реконсиляция: счёт на самом деле успел создаться в Kaspi — следуйте последнему статусу |
| `paid` | `partially_refunded` | первый частичный возврат |

**Никогда не происходят:**

| Переход | Комментарий |
|---------|-------------|
| терминальный → pending | кроме error → pending (реконсиляция) |
| paid → error | подавляется как инцидент |
| error → paid | невозможен |

**Без вебхука:**

- 202-ответ на отмену переводит счёт в cancelling БЕЗ вебхука; если Kaspi отказал в отмене (обычно счёт уже оплачен) — счёт тихо возвращается в pending, реальный статус (обычно paid) доставит синхронизация в течение минут. После 202 не считайте счёт отменённым — ждите вебхук.

### Сценарии реагирования

Когда вебхук приносит status=error (счёт) или status=failed (возврат) —
операция финализирована: сервис её больше не повторяет — «повторить»
здесь означает «создать новую операцию». Пока счёт в processing — сервис
сам доводит счёт до Kaspi и распределяет выставление во времени,
вмешиваться не нужно. Счёт может держаться в processing дольше часа —
это не зависание.

Отдельный случай — деактивированный кассир: после деактивации возвраты по
счетам, оплаченным через него, через API не проходят — запрос принимается,
но возврат завершается статусом failed (вебхук invoice.refunded). Такие
возвраты проводят вручную в приложении Kaspi Pay; нужные возвраты проводите
до деактивации кассира.

| Ошибка | Что произошло | Что делает система | Что делать вам |
|--------|---------------|--------------------|----------------|
| `client_not_found` | Номер телефона не зарегистрирован в Kaspi | Финализирует счёт сразу, без ретраев | Запросите у клиента другой номер и создайте новый счёт |
| `network_unavailable` | Сеть/Kaspi были недоступны | Ретраила сама; вебхук означает, что ретраи исчерпаны | Создайте новый счёт/возврат через 1–2 минуты |
| `session_transient` | Временный сбой сессии кассира | Автоматически инвалидировала сессию и ретраила | Создайте новый счёт позже; если повторяется — переподключите кассира в ЛК |
| `kaspi_throttled` | Kaspi ограничил частоту запросов кассы | Сервис распределяет выставление во времени: счета выставляются медленнее обычного; вебхук = финализация | Пока счёт в processing — ничего. После error — новый счёт через 2–3 минуты; снизьте темп создания счетов |
| `organization_not_configured` | К организации не подключён кассир Kaspi | Финализирует сразу | Подключите кассира: ЛК → Настройки → Авторизация Kaspi |
| `invoice_already_paid` | Попытка отменить уже оплаченный счёт | Отмену остановила; деньги получены | Не отменяйте; если нужно вернуть деньги — создайте возврат |
| `invoice_already_cancelled` | Счёт уже отменён | — | Ничего: желаемое состояние уже достигнуто |
| `invoice_not_found_in_kaspi` | Kaspi не нашёл счёт при отмене | Финализирует error | Обратитесь в поддержку |
| `refund_window_expired` | Истёк срок возврата (~14 дней) или возврат уже сделан | Возврат failed, ретраев нет | Не повторяйте; сообщите клиенту или обратитесь в поддержку |
| `qr_render_failed` | Не сформировалось изображение QR | Счёт финализирован в error (и 500-ответ, и вебхук) | Повторите POST /invoices/qr — создастся новый счёт |
| `kaspi_session_invalid` | Сессия кассира истекла в момент создания QR | Счёт финализирован в error; сессия инвалидирована | Повторите позже; если повторяется — переподключите кассира |
| `kaspi_error` | Неклассифицированная ошибка Kaspi | Зависит от причины; для QR — счёт error + вебхук | Читайте message/error_message; повторите или обратитесь в поддержку |
| `unknown_error` | Непредвиденная ошибка (в т.ч. исчерпаны все попытки создания) | Финализировала после всех ретраев | Создайте новый счёт; если повторяется — поддержка |

**Сценарии без error_code:**

- **Новый QR на той же кассе (QR сосуществуют)** — Создание нового QR на той же кассе НЕ отменяет прежние — старый QR остаётся pending и мониторится до своего терминала; supersede-вебхука cancelled больше нет. Два параллельных POST /invoices/qr оба получают 201 + pending (409 superseded — defensive-ветка, на практике недостижима). Реагируйте на paid/cancelled/expired по каждому invoice.id ОТДЕЛЬНО: если клиент оплатит оба QR — придут два paid.
- **Клиент отменил QR (cancelled)** — Вебхук cancelled по QR = отмена со стороны клиента (свернул или закрыл приложение Kaspi, не подтвердив оплату). Необратима — оплатить этот QR больше нельзя (ссылка одноразовая). Если отмена пришла после qr_scanned — откатите состояние «ожидается оплата» и при необходимости предложите новый QR.
- **Счёт истёк** — Вебхук expired. Phone-счёт — 24 часа. QR — минуты, и только когда Kaspi отдал терминал (не по локальному таймеру). При необходимости создайте новый счёт.
- **Оплата после отмены/истечения** — Вебхук paid после cancelled/expired: деньги получены — отгрузите или сделайте возврат.
- **Возврат к pending после error** — Корректирующий pending-вебхук (реконсиляция). Следуйте последнему статусу.

### `source` field

All webhook payloads contain a `source` field — the name of the API key that created the resource (invoice or subscription). Can be `null`.

### Retry Policy

- **Subscription webhooks** — До 11 попыток: первая доставка + 10 повторов при неуспехе. Интервалы нарастают: 10с, 30с, 1м, 1.5м, 2м, 5м, 10м, 15м, 30м, 1ч (всего ~2 часа)
- **Invoice webhooks** — До 11 попыток: первая доставка + 10 повторов при неуспехе. Интервалы нарастают: 10с, 30с, 1м, 1.5м, 2м, 5м, 10м, 15м, 30м, 1ч (всего ~2 часа)
- Your server must respond within 5 секунд на ответ (плюс до 3 секунд на установление соединения)
- HTTP 2xx = success, any other code = failure
- **Sandbox** — В sandbox-режиме invoice-вебхуки доставляются всего за 3 попытки (интервалы 5с, 15с). Вебхуки refund и subscription всегда используют полные 11 попыток — sandbox-сокращения для них нет.
- **3xx/4xx** — Ретраится только HTTP ≥500, ровно 429 и сетевые ошибки. Ответы 3xx и 4xx (кроме 429) НЕ ретраятся — попытка сразу фиксируется как доставленная. Повторить такую доставку можно только вручную: ЛК → Webhook-логи → Retry (доступно для записей со status=failed, cooldown между ручными повторами — 10 секунд).
- **Circuit breaker** — Если ваш endpoint стабильно недоступен, отправка на ключ приостанавливается: 5 подряд неудач → пауза 5 минут, 10 → 30 минут, 20 → 2 часа, 50 → полное отключение до ручного вмешательства. Вебхуки за время паузы НЕ доотправляются — сверяйте состояние через GET-методы. Любая успешная доставка (или успешный тест-вебхук из ЛК) сбрасывает счётчик. Статус виден в списке API-ключей.
- **Дедупликация** — Дедупликация на стороне клиента обязательна: ретрай после частичной доставки двум получателям пере-отправляет вебхук всем. Ключи дедупликации: (invoice.id, invoice.status) для invoice-событий, (refund.id, refund.status) для возвратов, (event, subscription.id, invoice_id) для событий подписки, а для subscription.period_skipped — (event, subscription.id, billing_period_start): invoice_id в нём нет. Отвечайте 200 OK быстро (≤5 секунд), обрабатывайте асинхронно.
- **subscription.*** — События subscription.* не пишутся в Webhook-логи ЛК: ручного retry и circuit breaker для них нет — сверяйте состояние подписки через GET /subscriptions/{id} и GET /subscriptions/{id}/invoices.
- **UTC** — Все даты в вебхуках — ISO 8601 в UTC (+00:00).

### Signature Verification

Header: `X-Webhook-Signature: sha256=<hex>`

#### JavaScript / Node.js
```javascript
import crypto from 'crypto'

function verifyWebhook(payload, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')
  const got = Buffer.from(signature || '')
  const exp = Buffer.from(expected)
  if (got.length !== exp.length) return false
  return crypto.timingSafeEqual(exp, got)
}

// Usage:
// const signature = req.headers['x-webhook-signature']
// const isValid = verifyWebhook(req.rawBody, signature, webhookSecret)
```

#### PHP
```php
<?php
function verifyWebhook(string $payload, string $signature, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $signature);
}

// Usage:
// $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
// $payload = file_get_contents('php://input');
// $isValid = verifyWebhook($payload, $signature, $webhookSecret);
```

#### Python
```python
import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# Usage:
# signature = request.headers.get('X-Webhook-Signature')
# is_valid = verify_webhook(request.body, signature, webhook_secret)
```

---

## Code Examples

### Закрыть смену и дождаться результата

#### JavaScript / Node.js
```javascript
const key = { 'X-API-Key': 'YOUR_API_KEY', 'Content-Type': 'application/json' }

// client_operation_id уникален на организацию: повтор с тем же ключом
// вернёт 409 cashbox_duplicate_operation с id уже идущей операции.
const start = await fetch('https://api.apipay.kz/api/v1/cashbox/shifts/close', {
  method: 'POST',
  headers: key,
  body: JSON.stringify({ client_operation_id: `close-${Date.now()}`, shift_number: 106 })
})
const accepted = await start.json()
const operationId = accepted.id ?? accepted.operation_id // 409 отдаёт operation_id

// Поллинг до терминального статуса. Альтернатива — вебхуки
// cashbox.shift_closed / cashbox.shift_close_failed.
let operation
do {
  await new Promise(r => setTimeout(r, 3000))
  operation = await (await fetch(`https://api.apipay.kz/api/v1/cashbox/operations/${operationId}`, { headers: key })).json()
} while (operation.status === 'pending' || operation.status === 'sending')

if (operation.status === 'failed') {
  // safe_to_retry === false -> повторять нельзя: неизвестно, закрылась ли смена.
  // Повтор всегда с НОВЫМ client_operation_id — прежний не освобождается.
  console.error(operation.error_code, operation.resolution?.safe_to_retry)
}
```

#### Python
```python
import time, requests

headers = {'X-API-Key': 'YOUR_API_KEY', 'Content-Type': 'application/json'}

accepted = requests.post(
    'https://api.apipay.kz/api/v1/cashbox/shifts/close',
    headers=headers,
    json={'client_operation_id': f'close-{int(time.time())}', 'shift_number': 106},
).json()
operation_id = accepted.get('id') or accepted.get('operation_id')

while True:
    operation = requests.get(f'https://api.apipay.kz/api/v1/cashbox/operations/{operation_id}', headers=headers).json()
    if operation['status'] in ('completed', 'failed'):
        break
    time.sleep(3)

if operation['status'] == 'failed':
    # Повторять только при resolution.safe_to_retry is True и новым client_operation_id.
    print(operation['error_code'], operation.get('resolution', {}).get('safe_to_retry'))
```

#### cURL
```bash
# Закрытие асинхронное: 202 -> поллинг операции (или вебхук cashbox.shift_closed).
curl -X POST https://api.apipay.kz/api/v1/cashbox/shifts/close \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "client_operation_id": "close-2026-08-10-01", "shift_number": 106 }'

# 202 -> { "id": 812, "status": "pending", "poll_url": "..." }
curl "https://api.apipay.kz/api/v1/cashbox/operations/812" -H "X-API-Key: YOUR_API_KEY"
```

### Отчёт по смене (ссылка живёт 15 минут)

#### JavaScript / Node.js
```javascript
const headers = { 'X-API-Key': 'YOUR_API_KEY' }

const { shifts } = await (await fetch(
  'https://api.apipay.kz/api/v1/cashbox/shifts?date_from=2026-08-09&date_to=2026-08-09',
  { headers }
)).json()

for (const shift of shifts) {
  const report = await (await fetch(`https://api.apipay.kz/api/v1/cashbox/shifts/${shift.id}/report`, { headers })).json()
  // Ссылка подписана и живёт ~15 минут — скачиваем сразу, в базе не храним.
  const pdf = await fetch(report.url)
  console.log(shift.shift_number, report.expires_at, pdf.status)
}
```

#### Python
```python
import requests

headers = {'X-API-Key': 'YOUR_API_KEY'}

shifts = requests.get(
    'https://api.apipay.kz/api/v1/cashbox/shifts',
    headers=headers,
    params={'date_from': '2026-08-09', 'date_to': '2026-08-09'},
).json()['shifts']

for shift in shifts:
    report = requests.get(f"https://api.apipay.kz/api/v1/cashbox/shifts/{shift['id']}/report", headers=headers).json()
    # Скачиваем сразу: ссылка временная (expires_at), хранить её бессмысленно.
    pdf = requests.get(report['url'])
    open(f"shift-{shift['shift_number']}.pdf", 'wb').write(pdf.content)
```

#### cURL
```bash
# Шаг 1 — найти смену за нужный день (обе границы обязательны, окно <= 31 дня).
curl "https://api.apipay.kz/api/v1/cashbox/shifts?date_from=2026-08-09&date_to=2026-08-09" \
  -H "X-API-Key: YOUR_API_KEY"

# Шаг 2 — получить временную ссылку и сразу скачать PDF.
curl "https://api.apipay.kz/api/v1/cashbox/shifts/118275707/report" -H "X-API-Key: YOUR_API_KEY"
# -> { "url": "https://...", "expires_at": "2026-08-10T09:15:00+05:00" }
```

### Сверка смены с нашими счетами

#### JavaScript / Node.js
```javascript
const headers = { 'X-API-Key': 'YOUR_API_KEY' }

// Шаг обязателен: без него сверка ответит 404 cashbox_shift_not_found.
const { shifts } = await (await fetch(
  'https://api.apipay.kz/api/v1/cashbox/shifts?date_from=2026-08-09&date_to=2026-08-09',
  { headers }
)).json()

const data = await (await fetch(
  `https://api.apipay.kz/api/v1/cashbox/reconciliation?shift_id=${shifts[0].id}`,
  { headers }
)).json()

// Обе цифры отдаются как есть: разницу сервис не считает — итог кассы Kaspi
// включает наличные и офлайн-продажи, которых нет среди счетов ApiPay.
console.log('Наши счета:', data.ours.net_amount)
console.log('Касса Kaspi:', data.kaspi.total_income)
data.discrepancies.forEach(d => console.log(d.code, d.message))
```

#### Python
```python
import requests

headers = {'X-API-Key': 'YOUR_API_KEY'}

shifts = requests.get(
    'https://api.apipay.kz/api/v1/cashbox/shifts',
    headers=headers,
    params={'date_from': '2026-08-09', 'date_to': '2026-08-09'},
).json()['shifts']

data = requests.get(
    'https://api.apipay.kz/api/v1/cashbox/reconciliation',
    headers=headers,
    params={'shift_id': shifts[0]['id']},
).json()

# Разницы и вердикта в ответе нет: сравнивать нечего, пока Kaspi не отдаёт
# безналичную часть выручки отдельной цифрой.
print('Наши счета:', data['ours']['net_amount'])
print('Касса Kaspi:', data['kaspi']['total_income'])
for reason in data['discrepancies']:
    print(reason['code'], reason['message'])
```

#### cURL
```bash
# Сначала список смен: сверка читает смену из уже полученных данных.
curl "https://api.apipay.kz/api/v1/cashbox/shifts?date_from=2026-08-09&date_to=2026-08-09" \
  -H "X-API-Key: YOUR_API_KEY"

# Без первого шага придёт 404 cashbox_shift_not_found.
curl "https://api.apipay.kz/api/v1/cashbox/reconciliation?shift_id=118275707" \
  -H "X-API-Key: YOUR_API_KEY"
```

### Автозакрытие смены и автоизъятие наличных

#### JavaScript / Node.js
```javascript
const headers = { 'X-API-Key': 'YOUR_API_KEY', 'Content-Type': 'application/json' }

const res = await fetch('https://api.apipay.kz/api/v1/cashbox/settings/auto-close', {
  method: 'PUT',
  headers,
  body: JSON.stringify({ enabled: true })
})

if (res.status === 503) {
  // cashbox_toggle_in_progress — эту настройку прямо сейчас меняет другой запрос;
  // cashbox_toggle_unavailable — значение проверить не удалось, изменение НЕ применено.
  console.warn((await res.json()).error_code)
} else {
  const { changed, new_value } = await res.json()
  console.log(changed ? 'изменили' : 'уже стояло', new_value)
}
```

#### Python
```python
import requests

headers = {'X-API-Key': 'YOUR_API_KEY', 'Content-Type': 'application/json'}

# Тот же вызов можно делать при каждом запуске: он идемпотентен по живому
# значению на кассе — changed=false просто означает "менять было нечего".
res = requests.put(
    'https://api.apipay.kz/api/v1/cashbox/settings/auto-close',
    headers=headers,
    json={'enabled': True},
)

if res.status_code == 503:
    print('повторить позже:', res.json()['error_code'])
else:
    print(res.json())  # {'changed': True, 'new_value': True}
```

#### cURL
```bash
curl "https://api.apipay.kz/api/v1/cashbox/settings" -H "X-API-Key: YOUR_API_KEY"

curl -X PUT https://api.apipay.kz/api/v1/cashbox/settings/auto-close \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
# -> { "changed": true, "new_value": true }
# changed=false означает, что на кассе уже стояло это значение.
```

### Наличные в кассе за день

#### JavaScript / Node.js
```javascript
// date можно не передавать — тогда это сегодняшний день (Asia/Almaty).
// Будущая дата -> 422.
const summary = await (await fetch('https://api.apipay.kz/api/v1/cashbox/summary?date=2026-08-10', {
  headers: { 'X-API-Key': 'YOUR_API_KEY' }
})).json()

console.log('В кассе сейчас:', summary.current_cash_balance)
console.log('Внесено / изъято:', summary.replenishment_sum, summary.withdrawal_sum)
// false -> Kaspi временно запретил кассовые операции: закрытие смены и
// переключение настроек сейчас не пройдут.
console.log('Операции разрешены:', summary.available_cashbox_actions)
```

#### Python
```python
import requests

summary = requests.get(
    'https://api.apipay.kz/api/v1/cashbox/summary',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    params={'date': '2026-08-10'},
).json()

# Суммы приходят строками "N.NN" и могут быть null — null означает
# "Kaspi не отдал поле", а не ноль.
print(summary['current_cash_balance'], summary['sale_cash_amt'])
```

#### cURL
```bash
# Только наличные: оплаты по счетам ApiPay сюда не попадают.
curl "https://api.apipay.kz/api/v1/cashbox/summary?date=2026-08-10" -H "X-API-Key: YOUR_API_KEY"
```

### Симуляция статуса (sandbox)

#### JavaScript / Node.js
```javascript
// Только для sandbox-счёта (is_sandbox: true) в статусе pending.
const res = await fetch('https://api.apipay.kz/api/v1/invoices/42/simulate-status', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_SANDBOX_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ status: 'paid' })
  // status: 'error' -> error_code: sandbox_simulated_error (+ optional error_message)
  // status: 'qr_scanned' -> только для QR-счёта, статус остаётся pending
})
const data = await res.json()
console.log('Simulated:', data.invoice.status)
```

#### Python
```python
import requests

# Только для sandbox-счёта (is_sandbox: true) в статусе pending.
res = requests.post(
    'https://api.apipay.kz/api/v1/invoices/42/simulate-status',
    headers={'X-API-Key': 'YOUR_SANDBOX_API_KEY', 'Content-Type': 'application/json'},
    json={'status': 'paid'}
)
print('Simulated:', res.json()['invoice']['status'])
```

#### cURL
```bash
# Только для sandbox-счёта (is_sandbox: true) в статусе pending.
curl -X POST https://api.apipay.kz/api/v1/invoices/42/simulate-status \
  -H "X-API-Key: YOUR_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "paid" }'
```

### Проверка доставки вебхука

#### JavaScript / Node.js
```javascript
// Убедиться, что по счёту 42 ушёл вебхук и приёмник ответил 2xx.
const res = await fetch(
  'https://api.apipay.kz/api/v1/webhook-logs?invoice_id=42&event=invoice.status_changed',
  { headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
const delivered = data.some(l => l.status === 'success')
console.log('Webhook delivered:', delivered)
```

#### Python
```python
import requests

# Убедиться, что по счёту 42 ушёл вебхук и приёмник ответил 2xx.
res = requests.get(
    'https://api.apipay.kz/api/v1/webhook-logs',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    params={'invoice_id': 42, 'event': 'invoice.status_changed'}
)
logs = res.json()['data']
print('Webhook delivered:', any(l['status'] == 'success' for l in logs))
```

#### cURL
```bash
# Убедиться, что по счёту 42 ушёл вебхук и приёмник ответил 2xx.
curl "https://api.apipay.kz/api/v1/webhook-logs?invoice_id=42&event=invoice.status_changed" \
  -H "X-API-Key: YOUR_API_KEY"
```

### Проверка доставки вебхука каталога

#### JavaScript / Node.js
```javascript
// Проверить, что позиция каталога 12345 обработана и вебхук ушёл.
const res = await fetch(
  'https://api.apipay.kz/api/v1/catalog/webhook-logs?catalog_item_id=12345&status=success',
  { headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
console.log('Deliveries:', data.length)
```

#### Python
```python
import requests

# Проверить, что позиция каталога 12345 обработана и вебхук ушёл.
res = requests.get(
    'https://api.apipay.kz/api/v1/catalog/webhook-logs',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    params={'catalog_item_id': 12345, 'status': 'success'}
)
logs = res.json()['data']
print('Deliveries:', len(logs))
```

#### cURL
```bash
# Проверить, что позиция каталога 12345 обработана и вебхук ушёл.
curl "https://api.apipay.kz/api/v1/catalog/webhook-logs?catalog_item_id=12345&status=success" \
  -H "X-API-Key: YOUR_API_KEY"
```

### Идемпотентная bulk-заливка каталога (Idempotency-Key)

#### JavaScript / Node.js
```javascript
// Точный повтор того же тела → 200 idempotent_replay; тот же ключ с другим телом → 409.
const res = await fetch('https://api.apipay.kz/api/v1/catalog', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'import-2026-08-26-0001'
  },
  body: JSON.stringify({
    items: [
      { name: 'Фильтр масляный', selling_price: 1800, unit_id: 1, external_ref: '1C-000123' }
    ]
    // альтернатива заголовку: idempotency_key: 'import-2026-08-26-0001'
  })
})
const { data, rejected, idempotent_replay } = await res.json()
console.log('Принято:', data.length, 'отклонено:', rejected.length, 'повтор:', !!idempotent_replay)
```

#### Python
```python
import requests

# Точный повтор того же тела → 200 idempotent_replay; тот же ключ с другим телом → 409.
res = requests.post(
    'https://api.apipay.kz/api/v1/catalog',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Idempotency-Key': 'import-2026-08-26-0001',
    },
    json={
        'items': [
            {'name': 'Фильтр масляный', 'selling_price': 1800, 'unit_id': 1, 'external_ref': '1C-000123'}
        ]
    },
)
body = res.json()
print('Принято:', len(body['data']), 'отклонено:', len(body['rejected']),
      'повтор:', body.get('idempotent_replay', False))
```

#### cURL
```bash
# Точный повтор того же тела с тем же Idempotency-Key не выполняется снова:
# приходит 200 с idempotent_replay: true. Тот же ключ с ДРУГИМ телом → 409
# idempotency_key_conflict.
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: import-2026-08-26-0001" \
  -d '{
    "items": [
      { "name": "Фильтр масляный", "selling_price": 1800, "unit_id": 1, "external_ref": "1C-000123" }
    ]
  }'
# 202 -> { "data": [...], "rejected": [] }. В бою позиции приходят
# status=pending, operation=create — это ещё не подтверждение из Kaspi.
# Остаток работы: GET https://api.apipay.kz/api/v1/catalog/queue
# Свой набор: GET https://api.apipay.kz/api/v1/catalog?external_refs[]=1C-000123
```

### Остаток очереди приёма каталога + ETA

#### JavaScript / Node.js
```javascript
// Остаток pending-очереди приёма каталога с блоком queue (ETA в минутах).
const res = await fetch('https://api.apipay.kz/api/v1/catalog/queue?sort_order=asc&per_page=100', {
  headers: { 'X-API-Key': 'YOUR_API_KEY' }
})
const { total, queue } = await res.json()
console.log('Remaining:', total, 'ETA min:', queue?.eta_minutes, 'state:', queue?.state)
```

#### Python
```python
import requests

# Остаток pending-очереди приёма каталога с блоком queue (ETA в минутах).
res = requests.get(
    'https://api.apipay.kz/api/v1/catalog/queue',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    params={'sort_order': 'asc', 'per_page': 100},
)
body = res.json()
print('Remaining:', body['total'], 'ETA min:', body['queue'].get('eta_minutes'))
```

#### cURL
```bash
# Остаток pending-очереди приёма каталога со слим-полями и блоком queue (ETA в минутах).
curl "https://api.apipay.kz/api/v1/catalog/queue?sort_order=asc&per_page=100" \
  -H "X-API-Key: YOUR_API_KEY"
# Ответ: { current_page, data:[{id, external_ref, name, queued_at}], total, queue:{ state, eta_minutes, ... } }
```

### Журнал ошибок приёма каталога

#### JavaScript / Node.js
```javascript
// Failed-позиции за окно по моменту отказа (failed_at).
const res = await fetch(
  'https://api.apipay.kz/api/v1/catalog/errors?from=2026-08-26T00:00:00Z&per_page=200',
  { headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
data.forEach((e) => console.log(e.external_ref, e.operation, e.error_code, e.failed_at))
```

#### Python
```python
import requests

# Failed-позиции за окно по моменту отказа (failed_at).
res = requests.get(
    'https://api.apipay.kz/api/v1/catalog/errors',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    params={'from': '2026-08-26T00:00:00Z', 'per_page': 200},
)
for e in res.json()['data']:
    print(e['external_ref'], e['operation'], e['error_code'], e['failed_at'])
```

#### cURL
```bash
# Failed-позиции за окно по МОМЕНТУ ОТКАЗА (failed_at), обезличенные тексты ошибок.
curl "https://api.apipay.kz/api/v1/catalog/errors?from=2026-08-26T00:00:00Z&per_page=200" \
  -H "X-API-Key: YOUR_API_KEY"
# Без from — окно последних 7 дней. Фильтр batch_id больше не принимается:
# даже пустой ?batch_id= вернёт 422 catalog_batch_filter_removed.
```

### Автономный тест без человека (полный цикл)

#### cURL
```bash
# Полный автономный цикл для ИИ-агента с sandbox-ключом (X-API-Key).
# Критерий успеха: каждый шаг подтверждается записью в /webhook-logs.
KEY="YOUR_SANDBOX_API_KEY"
BASE="https://api.apipay.kz/api/v1"

# 1. Создать sandbox-счёт (external_order_id_idempotency защищает от дублей при ретрае).
INVOICE_ID=$(curl -s -X POST "$BASE/invoices" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87770000001",
    "amount": 5000,
    "description": "Autotest order",
    "external_order_id_idempotency": "autotest-001"
  }' | jq -r '.id')

# 1a. Дождаться статуса pending — счёт создаётся в processing, переход
#     асинхронный (обычно 1-3 секунды). simulate-status требует pending.
#     (QR-счёт из POST /invoices/qr сразу pending — для него шаг не нужен.)
until [ "$(curl -s "$BASE/invoices/$INVOICE_ID" -H "X-API-Key: $KEY" | jq -r '.status')" = "pending" ]; do
  sleep 1
done

# 2. Перевести счёт в paid (симуляция оплаты, только sandbox).
curl -s -X POST "$BASE/invoices/$INVOICE_ID/simulate-status" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{ "status": "paid" }'

# 3. Убедиться, что ушёл вебхук invoice.status_changed (status=paid).
#    Совет: если ответ пуст — проверьте по ?invoice_id=$INVOICE_ID без event;
#    тип события есть в request_body записи.
curl -s "$BASE/webhook-logs?invoice_id=$INVOICE_ID&event=invoice.status_changed" \
  -H "X-API-Key: $KEY" | jq '.data[] | {event, status, response_status}'

# 4. Частичный возврат (в песочнице проходит без Kaspi; статус paid -> partially_refunded).
curl -s -X POST "$BASE/invoices/$INVOICE_ID/refund" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{ "amount": 2000 }'

# 5. Проверить вебхуки возврата: invoice.refunded + invoice.status_changed (partially_refunded).
curl -s "$BASE/webhook-logs?invoice_id=$INVOICE_ID" \
  -H "X-API-Key: $KEY" | jq '.data[] | {event, status}'

# Ветки других статусов (каждый — новый sandbox-счёт, дождаться pending как в шаге 1a):
#   simulate-status { "status": "cancelled" }  -> invoice.status_changed (cancelled)
#   simulate-status { "status": "expired" }    -> invoice.status_changed (expired)
#   simulate-status { "status": "error", "error_message": "..." }
#                                              -> error_code: sandbox_simulated_error
# QR-счёт: POST /invoices/qr с телом { "amount": 5000, "description": "QR autotest" }
#   (phone_number не нужен; счёт сразу pending) + simulate-status { "status": "qr_scanned" }:
#   -> invoice.qr_scanned (qr_substate=scanned, статус остаётся pending)
#
# Sandbox-вебхуки: 3 попытки, backoff 5с/15с. Успех = любой HTTP 2xx.
```

### Создание счёта

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/invoices', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: 10000,
    phone_number: '87001234567',
    description: 'Payment for order #123'
  })
})

const data = await response.json()
console.log('Invoice created:', data.id)
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'amount': 10000,
        'phone_number': '87001234567',
        'description': 'Payment for order #123'
    }
)

data = response.json()
print(f"Invoice created: {data['id']}")
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/invoices');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 10000,
        'phone_number' => '87001234567',
        'description' => 'Payment for order #123'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
echo "Invoice created: " . $response['id'];
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/invoices \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 10000,
    "phone_number": "87001234567",
    "description": "Payment for order #123"
  }'
```

### Создание счёта с корзиной

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/invoices', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone_number: '87001234567',
    description: 'Cart order',
    cart_items: [
      { catalog_item_id: 1, count: 2, price: 4500.00 },
      { catalog_item_id: 5, count: 3 }
    ],
    discount_percentage: 10
  })
})
// Response includes subtotal, discount_sum, discount_percentage
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'phone_number': '87001234567',
        'description': 'Cart order',
        'cart_items': [
            {'catalog_item_id': 1, 'count': 2, 'price': 4500.00},
            {'catalog_item_id': 5, 'count': 3}
        ],
        'discount_percentage': 10
    }
)
data = response.json()
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/invoices');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '87001234567',
        'description' => 'Cart order',
        'cart_items' => [
            ['catalog_item_id' => 1, 'count' => 2, 'price' => 4500.00],
            ['catalog_item_id' => 5, 'count' => 3]
        ],
        'discount_percentage' => 10
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/invoices \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87001234567",
    "description": "Cart order",
    "cart_items": [
      { "catalog_item_id": 1, "count": 2, "price": 4500.00 },
      { "catalog_item_id": 5, "count": 3 }
    ],
    "discount_percentage": 10
  }'
```

### Создание подписки

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/subscriptions', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone_number: '87001234567',
    amount: 5000,
    billing_period: 'monthly',
    description: 'Monthly subscription'
  })
})
const data = await response.json()
console.log('Subscription:', data.subscription.id)
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/subscriptions',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'phone_number': '87001234567',
        'amount': 5000,
        'billing_period': 'monthly',
        'description': 'Monthly subscription'
    }
)
data = response.json()
print(f"Subscription: {data['subscription']['id']}")
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/subscriptions');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '87001234567',
        'amount' => 5000,
        'billing_period' => 'monthly',
        'description' => 'Monthly subscription'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
echo "Subscription: " . $response['subscription']['id'];
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/subscriptions \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87001234567",
    "amount": 5000,
    "billing_period": "monthly",
    "description": "Monthly subscription"
  }'
```

### Создание подписки с корзиной

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/subscriptions', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone_number: '87001234567',
    billing_period: 'monthly',
    description: 'Monthly cart subscription',
    cart_items: [
      { catalog_item_id: 1, count: 2 },
      { catalog_item_id: 5, count: 1 }
    ]
  })
})
const data = await response.json()
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/subscriptions',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'phone_number': '87001234567',
        'billing_period': 'monthly',
        'description': 'Monthly cart subscription',
        'cart_items': [
            {'catalog_item_id': 1, 'count': 2},
            {'catalog_item_id': 5, 'count': 1}
        ]
    }
)
data = response.json()
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/subscriptions');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '87001234567',
        'billing_period' => 'monthly',
        'description' => 'Monthly cart subscription',
        'cart_items' => [
            ['catalog_item_id' => 1, 'count' => 2],
            ['catalog_item_id' => 5, 'count' => 1]
        ]
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/subscriptions \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87001234567",
    "billing_period": "monthly",
    "description": "Monthly cart subscription",
    "cart_items": [
      { "catalog_item_id": 1, "count": 2 },
      { "catalog_item_id": 5, "count": 1 }
    ]
  }'
```

### Загрузка изображения и создание товара

#### JavaScript / Node.js
```javascript
// Step 1: Upload image
const formData = new FormData()
formData.append('image', imageFile)

const uploadRes = await fetch('https://api.apipay.kz/api/v1/catalog/upload-image', {
  method: 'POST',
  headers: { 'X-API-Key': 'YOUR_API_KEY' },
  body: formData
})
const { image_id } = await uploadRes.json()

// Step 2: Create product with image
const productRes = await fetch('https://api.apipay.kz/api/v1/catalog', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    items: [{
      name: 'Coffee Latte',
      selling_price: 1800,
      unit_id: 1,
      image_id
    }]
  })
})
```

#### Python
```python
import requests

# Step 1: Upload image
upload_res = requests.post(
    'https://api.apipay.kz/api/v1/catalog/upload-image',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    files={'image': open('product.jpg', 'rb')}
)
image_id = upload_res.json()['image_id']

# Step 2: Create product with image
product_res = requests.post(
    'https://api.apipay.kz/api/v1/catalog',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'items': [{
            'name': 'Coffee Latte',
            'selling_price': 1800,
            'unit_id': 1,
            'image_id': image_id
        }]
    }
)
```

#### PHP
```php
<?php
// Step 1: Upload image
$ch = curl_init('https://api.apipay.kz/api/v1/catalog/upload-image');
$cfile = new CURLFile('product.jpg', 'image/jpeg');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['X-API-Key: YOUR_API_KEY'],
    CURLOPT_POSTFIELDS => ['image' => $cfile],
    CURLOPT_RETURNTRANSFER => true
]);
$imageId = json_decode(curl_exec($ch), true)['image_id'];
curl_close($ch);

// Step 2: Create product with image
$ch = curl_init('https://api.apipay.kz/api/v1/catalog');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'items' => [[
            'name' => 'Coffee Latte',
            'selling_price' => 1800,
            'unit_id' => 1,
            'image_id' => $imageId
        ]]
    ]),
    CURLOPT_RETURNTRANSFER => true
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
```

#### cURL
```bash
# Step 1: Upload image
IMAGE_ID=$(curl -s -X POST https://api.apipay.kz/api/v1/catalog/upload-image \
  -H "X-API-Key: YOUR_API_KEY" \
  -F "image=@product.jpg" | jq -r '.image_id')

# Step 2: Create product with image
curl -X POST https://api.apipay.kz/api/v1/catalog \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"items\": [{
      \"name\": \"Coffee Latte\",
      \"selling_price\": 1800,
      \"unit_id\": 1,
      \"image_id\": \"$IMAGE_ID\"
    }]
  }"
```

### Возврат средств

#### JavaScript / Node.js
```javascript
// Full refund
await fetch('https://api.apipay.kz/api/v1/invoices/42/refund', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ reason: 'Customer request' })
})

// Partial refund
await fetch('https://api.apipay.kz/api/v1/invoices/42/refund', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ amount: 5000, reason: 'Partial return' })
})
```

#### Python
```python
import requests

# Full refund
requests.post(
    'https://api.apipay.kz/api/v1/invoices/42/refund',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'reason': 'Customer request'}
)

# Partial refund
requests.post(
    'https://api.apipay.kz/api/v1/invoices/42/refund',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'amount': 5000, 'reason': 'Partial return'}
)
```

#### PHP
```php
<?php
// Full refund
$ch = curl_init('https://api.apipay.kz/api/v1/invoices/42/refund');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'reason' => 'Customer request'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);
curl_exec($ch);
curl_close($ch);

// Partial refund
$ch = curl_init('https://api.apipay.kz/api/v1/invoices/42/refund');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 5000,
        'reason' => 'Partial return'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);
curl_exec($ch);
curl_close($ch);
```

#### cURL
```bash
# Full refund
curl -X POST https://api.apipay.kz/api/v1/invoices/42/refund \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Customer request"}'

# Partial refund
curl -X POST https://api.apipay.kz/api/v1/invoices/42/refund \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000, "reason": "Partial return"}'
```

### QR-счёт для оплаты сразу: по ссылке или по QR

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/invoices/qr', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: 5000,
    description: 'Заказ №123',
    external_order_id: 'order-123'
  })
})

const data = await response.json()

// Один счёт — два канала доставки, оба для оплаты прямо сейчас:
// qr_token_url — ссылка Kaspi на этот QR-счёт: кнопка «Оплатить в Kaspi» на телефоне
//   покупателя (откроется приложение, сканировать не нужно), «на потом» не отправляйте;
// qr_image_url — тот же платёж картинкой QR для экрана кассы.
document.getElementById('pay-link').href = data.qr_token_url
document.getElementById('qr').src = data.qr_image_url

// Оба канала действуют до qr_expires_at, продления нет — константу не зашивайте.
console.log('QR expires at:', data.qr_expires_at)
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices/qr',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'amount': 5000,
        'description': 'Заказ №123',
        'external_order_id': 'order-123'
    }
)

data = response.json()
# qr_token_url — ссылка Kaspi на этот QR-счёт: откройте на телефоне покупателя
# или покажите QR (qr_image_url — тот же платёж готовой картинкой для экрана кассы).
print(data['qr_token_url'], data['qr_image_url'])
# Оба канала действуют до qr_expires_at, продления нет.
print(f"QR expires at: {data['qr_expires_at']}")
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/invoices/qr');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 5000,
        'description' => 'Заказ №123',
        'external_order_id' => 'order-123'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// qr_token_url — ссылка Kaspi на этот QR-счёт: откройте на телефоне покупателя
// или покажите QR ($response['qr_image_url'] — тот же платёж готовой картинкой для экрана кассы).
echo $response['qr_token_url'];
// Оба канала действуют до qr_expires_at, продления нет.
echo "QR expires at: " . $response['qr_expires_at'];
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/invoices/qr \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "description": "Заказ №123",
    "external_order_id": "order-123"
  }'

# 201 приходит сразу, со статусом pending:
# {
#   "id": 1, "status": "pending",
#   "qr_token_url": "https://qr.kaspi.kz/...",  <- ссылка Kaspi на этот QR-счёт:
#                                                  откройте на телефоне покупателя
#   "qr_image_url": "https://.../storage/qr/abc.png",
#   "qr_expires_at": "2026-01-10T12:03:00+00:00"
# }
```

### QR-счёт с корзиной (оплата по ссылке или QR)

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/invoices/qr', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    description: 'Заказ №123',
    cart_items: [
      { catalog_item_id: 608400, count: 2, price: 1500 }
    ],
    discount_percentage: 10
  })
})

const data = await response.json()
// data.qr_token_url — ссылка Kaspi на этот QR-счёт: откройте на телефоне покупателя;
// data.qr_image_url — тот же платёж готовым PNG для экрана кассы.
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices/qr',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'description': 'Заказ №123',
        'cart_items': [
            {'catalog_item_id': 608400, 'count': 2, 'price': 1500}
        ],
        'discount_percentage': 10
    }
)
data = response.json()
# qr_token_url — ссылка Kaspi на этот QR-счёт: откройте на телефоне покупателя или покажите QR
print(data['qr_token_url'], data['qr_image_url'], data['qr_expires_at'])
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/invoices/qr');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'description' => 'Заказ №123',
        'cart_items' => [
            ['catalog_item_id' => 608400, 'count' => 2, 'price' => 1500]
        ],
        'discount_percentage' => 10
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// qr_token_url — ссылка Kaspi на этот QR-счёт: откройте на телефоне покупателя или покажите QR
echo $response['qr_token_url'];
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/invoices/qr \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Заказ №123",
    "cart_items": [
      { "catalog_item_id": 608400, "count": 2, "price": 1500 }
    ],
    "discount_percentage": 10
  }'

# В ответе, как и у QR-счёта одной суммой:
# qr_token_url — ссылка Kaspi на этот QR-счёт (откройте на телефоне покупателя),
# qr_image_url — готовый PNG, qr_expires_at — до какого момента платёж жив.
```

### Проверка номера клиента

#### JavaScript / Node.js
```javascript
const response = await fetch('https://api.apipay.kz/api/v1/clients/check', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ phone: '77001234567' })
})

const data = await response.json()
// { phone: '87001234567', has_kaspi: true, client_name: 'Иван И.' }
if (!data.has_kaspi) {
  // Клиент не зарегистрирован в Kaspi — попросите другой номер
}
```

#### Python
```python
import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/clients/check',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'phone': '77001234567'}
)

data = response.json()
# {'phone': '87001234567', 'has_kaspi': True, 'client_name': 'Иван И.'}
if not data['has_kaspi']:
    pass  # Клиент не зарегистрирован в Kaspi
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/clients/check');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode(['phone' => '77001234567']),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// ['phone' => '87001234567', 'has_kaspi' => true, 'client_name' => 'Иван И.']
curl_close($ch);
```

#### cURL
```bash
curl -X POST https://api.apipay.kz/api/v1/clients/check \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone": "77001234567"}'
```

### Превью фискального чека

#### JavaScript / Node.js
```javascript
const res = await fetch('https://api.apipay.kz/api/v1/receipts/preview', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ payment_type: 3, total_price: 10 })
})
const { data } = await res.json()
console.log('Preview lines:', data)
```

#### Python
```python
import requests

res = requests.post(
    'https://api.apipay.kz/api/v1/receipts/preview',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'payment_type': 3, 'total_price': 10}
)
lines = res.json()['data']
print('Preview lines:', lines)
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/receipts/preview');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'payment_type' => 3,
        'total_price' => 10
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// $response['data'] — строки превью чека
curl_close($ch);
```

#### cURL
```bash
# Синхронное превью строк чека для UI. payment_type: 3 — наличные, 5 — POS другого банка.
curl -X POST https://api.apipay.kz/api/v1/receipts/preview \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_type": 3,
    "total_price": 10
  }'
# 200 → { "data": [ { "Title": "Способ оплаты", "Subtitle": "Наличные", "isBoldText": false } ] }
```

### Выбить фискальный чек

#### JavaScript / Node.js
```javascript
const res = await fetch('https://api.apipay.kz/api/v1/receipts', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    payment_type: 3,
    client_operation_id: 'pos-cash-0042',
    received_amt: 5000,
    cart_items: [
      { catalog_item_id: 1, quantity: 2, price: 1800 },
      { catalog_item_id: 5, quantity: 1 }
    ]
  })
})
const receipt = await res.json()
// 202 pending → опрашивайте GET /receipts/{id} или ждите вебхук
console.log('Receipt queued:', receipt.id, receipt.status)
```

#### Python
```python
import requests

res = requests.post(
    'https://api.apipay.kz/api/v1/receipts',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'payment_type': 3,
        'client_operation_id': 'pos-cash-0042',
        'received_amt': 5000,
        'cart_items': [
            {'catalog_item_id': 1, 'quantity': 2, 'price': 1800},
            {'catalog_item_id': 5, 'quantity': 1}
        ]
    }
)
receipt = res.json()
print('Receipt queued:', receipt['id'], receipt['status'])
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/receipts');

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'payment_type' => 3,
        'client_operation_id' => 'pos-cash-0042',
        'received_amt' => 5000,
        'cart_items' => [
            ['catalog_item_id' => 1, 'quantity' => 2, 'price' => 1800],
            ['catalog_item_id' => 5, 'quantity' => 1]
        ]
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// 202 → $response['id'], $response['status'] === 'pending'
curl_close($ch);
```

#### cURL
```bash
# Асинхронно выбивает чек в Kaspi OFD. Позиции — из каталога по catalog_item_id
# (только фискальные, с НТИН). client_operation_id — ключ идемпотентности.
# received_amt — наличные, полученные от клиента (для сдачи).
curl -X POST https://api.apipay.kz/api/v1/receipts \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_type": 3,
    "client_operation_id": "pos-cash-0042",
    "received_amt": 5000,
    "cart_items": [
      { "catalog_item_id": 1, "quantity": 2, "price": 1800 },
      { "catalog_item_id": 5, "quantity": 1 }
    ]
  }'
# 202 → { "id": 4210, "status": "pending", "client_operation_id": "pos-cash-0042" }
# Итог узнайте через GET https://api.apipay.kz/api/v1/receipts/4210 или вебхук receipt.issued/receipt.failed.
```

### Статус фискального чека

#### JavaScript / Node.js
```javascript
const res = await fetch('https://api.apipay.kz/api/v1/receipts/4210', {
  headers: { 'X-API-Key': 'YOUR_API_KEY' }
})
const receipt = await res.json()
if (receipt.status === 'issued') {
  console.log('Receipt link:', receipt.link, 'FPD:', receipt.fpd)
} else if (receipt.status === 'failed') {
  console.log('Failed:', receipt.error_code)
}
```

#### Python
```python
import requests

res = requests.get(
    'https://api.apipay.kz/api/v1/receipts/4210',
    headers={'X-API-Key': 'YOUR_API_KEY'}
)
receipt = res.json()
if receipt['status'] == 'issued':
    print('Receipt link:', receipt['link'], 'FPD:', receipt['fpd'])
elif receipt['status'] == 'failed':
    print('Failed:', receipt['error_code'])
```

#### PHP
```php
<?php
$ch = curl_init('https://api.apipay.kz/api/v1/receipts/4210');

curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ['X-API-Key: YOUR_API_KEY'],
    CURLOPT_RETURNTRANSFER => true
]);

$receipt = json_decode(curl_exec($ch), true);
// $receipt['status']: 'pending' | 'issued' | 'failed'
curl_close($ch);
```

#### cURL
```bash
# Поллинг статуса чека: pending | issued | failed.
curl "https://api.apipay.kz/api/v1/receipts/4210" \
  -H "X-API-Key: YOUR_API_KEY"
# issued → { "status": "issued", "fpd": "000000000000",
#   "operation_id": "KKM00000000", "link": "https://receipt.kaspi.kz/...",
#   "shift_number": null }
```

### История фискальных чеков

#### JavaScript / Node.js
```javascript
// Чеки за календарный день мерчанта (Asia/Almaty): голая дата включает весь день.
const params = new URLSearchParams({
  from: '2026-07-14',
  to: '2026-07-14',
  status: 'issued',
  per_page: '20',
})
const res = await fetch(`https://api.apipay.kz/api/v1/receipts?${params}`, {
  headers: { 'X-API-Key': 'YOUR_API_KEY' }
})
const { data, total } = await res.json()
data.forEach((r) => console.log(r.id, r.status, r.total_price, r.link))
console.log('total:', total)
```

#### Python
```python
import requests

# Чеки за календарный день мерчанта (Asia/Almaty): голая дата включает весь день.
res = requests.get(
    'https://api.apipay.kz/api/v1/receipts',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    params={'from': '2026-07-14', 'to': '2026-07-14', 'status': 'issued', 'per_page': 20},
)
body = res.json()
for r in body['data']:
    print(r['id'], r['status'], r['total_price'], r['link'])
print('total:', body['total'])
```

#### cURL
```bash
# Чеки за календарный день мерчанта (Asia/Almaty): голая дата включает весь день.
curl "https://api.apipay.kz/api/v1/receipts?from=2026-07-14&to=2026-07-14&status=issued&per_page=20" \
  -H "X-API-Key: YOUR_API_KEY"
# Плоская пагинация: { "current_page": 1, "data": [...], "total": 12 }.
# Список чеков доступен всегда.
```

> Examples cover the most common integration scenarios. The detailed sections above cover the core endpoints; the complete reference for all endpoints is the OpenAPI spec: https://apipay.kz/openapi.json

---

## Error Response Format

### Validation (422)

```json
{
  "message": "The given data was invalid.",
  "errors": {
    "phone_number": ["The phone number field is required."],
    "amount": ["The amount field is required."]
  }
}
```

### Not_found (404)

```json
{
  "message": "Invoice not found"
}
```

### Unauthorized (401)

```json
{
  "message": "Invalid or missing API key"
}
```

---

## Error Codes

Ошибки приходят в нескольких формах — не путайте их между собой:

- **HTTP-статус** (`400`, `401`, `429` …) — общий класс ошибки, присутствует всегда.
- **Поле `error`** в теле ответа — конкретная причина синхронной ошибки. Значения бывают
  двух видов: машинные коды в snake_case (`organization_required`,
  `kaspi_session_not_configured`) и английские фразы целиком
  (`Organization not found or not verified`, `Invoice cannot be cancelled`).
  **Не сравнивайте текст `error` в коде** — для ветвления используйте `error_code`.
- **Поле `error_message`** — человекочитаемый текст асинхронной ошибки Kaspi (счёт создан
  со статусом `processing` и позже перешёл в `error`). Фиксированных кодов у Kaspi нет.
- **Поле `error_code`** (новое) — стабильный snake_case-код из фиксированного каталога.
  Стройте switch-логику по нему, а не по тексту `error`/`error_message`.

В колонке «Код» ниже значения сгруппированы по тому, где именно они появляются.

### HTTP-статусы (общие для всех эндпоинтов)

| Code | Description |
|------|-------------|
| 400 | Bad Request — некорректный запрос или недопустимое состояние. Точная причина — в поле message или error |
| 401 | Unauthorized — API-ключ отсутствует, неверен, истёк или не привязан к организации; либо аккаунт деактивирован |
| 403 | Forbidden — организация заморожена (suspended) или не верифицирована для рабочего режима |
| tariff_inactive (403) | Нет действующей подписки на ApiPay — оплатите тариф в кабинете. Закрывает все платные операции: создание, отмену и возврат счёта, чеки, изменяющий каталог, создание, изменение и возобновление подписок, проверку номера клиента. Грейса нет — блокировка сразу после expires_at (это поле приходит в теле ответа; null, если тариф не оформлялся). Чтение (GET), оплата тарифа, настройки, подключение кассира, а также приостановка и отмена подписок продолжают работать. В песочнице тариф не требуется |
| 404 | Not Found — ресурс не найден или принадлежит другой организации |
| 422 | Validation Error (машинный код validation_error) — ошибка валидации полей; детали в объекте errors. Например, POST /catalog/image без файла image отвечает именно так. Повтор того же запроса даст тот же результат — правьте запрос. |
| 429 | Too Many Requests — превышен общий лимит Public API (200 запросов/мин на API-ключ); смотрите заголовок Retry-After. Отдельно: POST /clients/check ограничен 60 запросами/мин и 10 000 запросами/сутки на API-ключ, а POST /invoices/qr — 200 QR/мин на организацию (на этом 429 заголовка Retry-After нет) |
| 500 | Server Error — внутренняя ошибка сервера |
| 502 | Bad Gateway — ошибка на стороне Kaspi API |
| 503 | Service Unavailable — сессия Kaspi недействительна или истекла |
| organization_archived (403) | Организация, к которой привязан ключ, отправлена в архив. Отказ приходит на ЛЮБУЮ операцию этого ключа. Сам ключ остаётся активным, и перевыпуск ничего не меняет — доступ возвращает владелец аккаунта. Не путайте с 401: там ключ неверен, истёк или деактивирован, и перевыпуск как раз помогает. Что именно случилось с организацией, ответ не раскрывает. |

### Права интеграции (ключи, выданные партнёром)

| Code | Description |
|------|-------------|
| grant_not_granted (403) | Операция вне набора прав, который мерчант выдал вашей интеграции. Один и тот же ответ приходит на три случая — права нет, операция интеграциям не выдаётся никогда, операции нет в каталоге прав. По ответу они не различаются. Повтор бессмысленен — набор меняет мерчант в своём кабинете. |
| channel_required (403) | Живого доступа к организации у интеграции нет: мерчант его отозвал. Ключ при этом продолжает существовать и не деактивируется — доступ восстанавливает мерчант. Отдельный код от grant_not_granted намеренно: «не хватает права» и «доступа нет вовсе» лечатся по-разному. |
| grant_snapshot_unreadable (403) | Права интеграции временно недоступны — это временный сбой на нашей стороне, а не отказ в праве. Повторите позже; если отказ повторяется — обратитесь в поддержку. |

### Поле «error» при создании счёта (POST /invoices, /invoices/qr)

| Code | Description |
|------|-------------|
| organization_required (400) | Организация не подключена — создайте sandbox-организацию для тестов или подключите кассира Kaspi |
| Organization not found or not verified (400) | Рабочий режим: организация не верифицирована. Дождитесь верификации или тестируйте в песочнице |
| kaspi_session_not_configured (400) | Кассир Kaspi не подключён. Подключите его в кабинете (Настройки → Авторизация Kaspi) или через поддержку (WhatsApp +7 700 307 65 12) |
| kaspi_session_invalid (503) | Сессия кассира Kaspi истекла или сброшена. Переподключите кассира — запросите новый SMS-код |
| connection_ambiguous (422) | У организации несколько активных касс, основная не выбрана — передайте kaspi_connection_id |
| sandbox_invoice_limit (400) | Достигнут лимит тестовых счетов (1000 на организацию) — очистите песочницу в кабинете |
| duplicate_idempotency_key (409) | Идемпотентность: активный счёт с таким external_order_id_idempotency уже существует — повторный POST /invoices с тем же ключом не создаёт дубликат (в ответе invoice_id и status существующего счёта). Перевыставление возможно, только если предыдущий счёт с этим external_order_id_idempotency находится в статусе expired, cancelled или error |
| amount_must_be_whole_tenge (422) | Сумма счёта на номер телефона должна быть целой: тиыны Kaspi по такому счёту не принимает. Проверяется и голая amount, и итог корзины после скидок. Округлите сумму или выставьте счёт через POST /invoices/qr — там суммы с тиынами принимаются. В POST /invoices/bulk приходит по позиции: она попадает в invoices[] как failed, остальные создаются |
| invoices_disabled (503) | Приём новых счетов временно приостановлен — идут технические работы. Счёт не создан, повторите позже. |

### Поле «error» только для QR-счёта (POST /invoices/qr)

| Code | Description |
|------|-------------|
| qr_discount_unsupported (422) | Скидку на QR к этой корзине применить нельзя — отказ приходит до обращения в Kaspi, счёт не создаётся (в песочнице так же). Приходит на POST /invoices/qr (в том числе static: true) и POST /static-qr. Причины: цена позиции задана в тиынах (в cart_items[].price или в каталоге); скидка обнуляет позицию — на дешёвой позиции скидка округляется вверх до целого тенге (1 ₸ со скидкой 10 % это скидка 1 ₸). Процент скидки принимается только целый (1–99); дробный процент отбивается отдельно — 422 с errors.discount_percentage, а не этим кодом. Что делать: задайте целые цены в тенге и целый процент, либо выставьте счёт со скидкой телефонным POST /invoices — там скидка считается как прежде |
| qr_rate_limit (429) | Слишком много QR-запросов для организации (лимит 200/мин) — подождите минуту. Заголовок Retry-After на этом 429 не возвращается (в отличие от общего лимита Public API 200/мин) |
| qr_render_failed (500) | Не удалось сформировать изображение QR-кода — повторите запрос позже |
| kaspi_error (502) | Kaspi API вернул ошибку при создании QR-токена — повторите позже |
| idempotency_conflict (409) | Одноразовая платёжная ссылка с таким external_order_id_idempotency уже выпущена (POST /invoices/qr с static=true). Дубликат не создаётся: в ответе приходят static_qr_id и payment_url уже существующей ссылки — отправьте покупателю их. Ключ закрепляется за ссылкой в отдельном пространстве организации: в счёт он не переносится и не освобождается, когда истекает QR этой ссылки. У обычного QR-счёта дубль ключа приходит другим кодом — duplicate_idempotency_key. Доставка: sync HTTP 409 |
| static_qr_disabled (403) | Режим одноразовой платёжной ссылки (`static=true`) недоступен — ссылка не создана. Повтор не поможет, и для отдельной организации режим не включается: используйте статический QR (`POST /static-qr`) — этим кодом он не отбивается. Доставка: sync HTTP 403 |

### Поле «error» при отмене и возврате

| Code | Description |
|------|-------------|
| Invoice cannot be cancelled (400) | Отменить можно только счёт в статусе pending или processing |
| Invoice is not refundable (400) | Возврат возможен только по оплаченному счёту, ещё не возвращённому полностью |
| Refund amount exceeds available amount (400) | Сумма возврата больше доступной — смотрите available_for_refund в GET /invoices/{id} |
| qr_cancel_unsupported (409) | QR-счёт (is_qr_token: true) отменить нельзя — отмена для него не поддерживается. Статус счёта не меняется, в Kaspi ничего не уходит. В теле ответа есть expires_at — момент, после которого QR перестанет быть оплачиваемым; дождитесь статуса expired или выставьте новый счёт |

### Поле «error» при работе с подписками

| Code | Description |
|------|-------------|
| sandbox_subscription_limit (400) | Достигнут лимит тестовых подписок (10 на организацию) — очистите песочницу |
| Organization not verified (403) | Подписки в рабочем режиме доступны только верифицированной организации |
| subscription_not_active (409) | Пропустить период можно только у подписки в статусе active: у подписки на паузе, отменённой или истёкшей пропускать нечего. Приходит на POST /subscriptions/{id}/skip-period и /skip-period/preview; поля error и error_code одинаковы. Повтор того же запроса не поможет. |
| subscription_cycles_exhausted (409) | По подписке уже получены все оплаты total_cycles — пропускать нечего, возобновить её тоже нельзя. Приходит на POST /subscriptions/{id}/skip-period и /skip-period/preview. Повтор не поможет: если услуга продолжается, создайте новую подписку. |
| subscription_busy (409) | Прямо сейчас по подписке выставляется счёт — период не пропущен, ничего не изменено. Повторите POST /subscriptions/{id}/skip-period примерно через минуту с тем же billing_period_start. |
| skip_period_changed (409) | Пока человек подтверждал пропуск, период сменился — например, вышел новый счёт. Этот запрос ничего не пропустил. В теле ответа — свежий объект skip: покажите человеку, какой период будет пропущен теперь, и после подтверждения повторите POST /subscriptions/{id}/skip-period с новым billing_period_start. Вслепую не повторяйте. Повтор с уже пропущенным периодом — не этот отказ, а 200 с skip.replayed: true. |

### Асинхронные ошибки Kaspi: status=error / поле error_message (HTTP-кода нет)

| Code | Description |
|------|-------------|
| status=error | Счёт создан (201, статус processing), но Kaspi не смог его обработать — статус сменился на error. Причина текстом в поле error_message (GET /invoices/{id}). У Kaspi нет фиксированных кодов — текст приходит как есть |
| error_message: номер не в Kaspi | «Этот номер телефона не зарегистрирован в Kaspi. Укажите номер с установленным приложением Kaspi.» — у клиента нет приложения Kaspi; попросите другой номер |
| error_message: сбой Kaspi | «Ошибка обработки платежа. Обратитесь в поддержку» или «Не удалось обработать счёт после нескольких попыток» — временный сбой Kaspi; повторите создание счёта позже |

### Поле error_code — стабильный машинный код (новое, рекомендуется)

| Code | Description |
|------|-------------|
| error_code (новое поле) | Стабильный snake_case-код ошибки из фиксированного каталога. Присутствует в JSON-ответах об ошибках и в webhook-объектах invoice (для status=error) и refund (для status=failed). Поля message/error сохранены без изменений. Определяйте тип ошибки по error_code, текст — для показа пользователю. У каждого кода ниже указана «Доставка» — приходит ли он асинхронно (в webhook) или синхронно (HTTP-ответ с кодом) |
| error_code: network_unavailable | Сервис временно недоступен (сбой сети/Kaspi). Можно повторить позже. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: session_transient | Временные проблемы авторизации Kaspi. Можно повторить попытку. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: client_not_found | Номер телефона не зарегистрирован в Kaspi. Не повторяемая — попросите другой номер. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: kaspi_throttled | Kaspi ограничил частоту запросов. Повторите через 2–3 минуты. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: refund_window_expired | Срок возврата истёк или возврат уже сделан (часто для refund status=failed). Доставка: async — приходит в invoice.refunded (refund.error_code) |
| error_code: refund_rejected_by_kaspi | Kaspi отклонил возврат по этой операции. Отказ может быть временным — попробуйте позже ещё раз. Если не получится, выполните возврат вручную в приложении Kaspi Pay. Сервис сам этот возврат не повторяет: строка сразу финальная, повтор — ваш новый запрос на возврат. Доставка: async — приходит в invoice.refunded (refund.error_code) |
| error_code: refund_execution_uncertain | Результат денежного запроса не подтверждён. Возврат остаётся processing, повтор и переключение способа запрещены. Обратитесь в поддержку для проверки. Доставка: GET /invoices/{id}/refunds, refund.error_code; failed-вебхук не отправляется. |
| error_code: refund_requires_buyer_confirmation | Kaspi требует подтверждение покупателя. Создайте QR-возврат через POST /api/v1/qr-refunds/links и попросите покупателя отсканировать QR-код. Доставка: async — приходит в invoice.refunded (refund.error_code) |
| error_code: invoice_already_paid | Счёт уже оплачен. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: invoice_already_cancelled | Счёт уже отменён. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: invoice_not_found_in_kaspi | Счёт не найден в Kaspi. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: organization_not_configured | Организация не настроена (нет рабочей привязки Kaspi). Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: unknown_error | Непредвиденная ошибка обработки — обратитесь в поддержку. Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: qr_render_failed | Не удалось сформировать изображение QR-кода — повторите запрос. Доставка: sync HTTP 500 + async (webhook invoice.status_changed, status=error) |
| error_code: kaspi_session_invalid | Сессия кассира Kaspi истекла или сброшена — переподключите кассира. Доставка: sync HTTP 503 + async (webhook invoice.status_changed, status=error) |
| error_code: kaspi_session_unavailable | Не удалось проверить сессию Kaspi — попробуйте позже. Доставка: sync HTTP 503; на GET /invoices/{id}/receipt этот код приходит с HTTP 409 |
| error_code: manager_throttled | Слишком много операций — попробуйте позже. Доставка: sync HTTP 429 |
| error_code: whatsapp_otp_throttled | Слишком частые запросы кода WhatsApp — попробуйте позже. Доставка: sync HTTP 429 |
| error_code: whatsapp_gateway_error | Не удалось отправить код WhatsApp — попробуйте ещё раз. Доставка: sync HTTP 500 |
| error_code: subscription_payment_failed | Не удалось создать счёт для оплаты подписки — попробуйте позже. Доставка: sync HTTP 502 |
| error_code: kaspi_error | Kaspi API вернул ошибку — повторите позже. Текст message содержит конкретную причину от Kaspi. Доставка: sync HTTP 502 + async (для QR-счетов: webhook invoice.status_changed, status=error) |
| error_code: trial_daily_limit | Антифрод/триал: на пробном тарифе превышен дневной лимит создания счетов (50 счетов/сутки). Дождитесь следующего дня или оформите подписку. Доставка: sync HTTP 429 + заголовок Retry-After |
| error_code: tariff_limit_reached | Исчерпан лимит счетов оплаченного тарифа (Старт 30, Бизнес 100, Про 300, Про Макс 600 в сутки). Считаются только счета, созданные через API; счета из кабинета и песочницы в лимит не входят. Разовое превышение не блокирует: отказ приходит при систематическом превышении лимита либо при исчерпанном бюджете у организаций на помесячном подсчёте. meta.mode различает окна (daily — расчётные сутки, monthly — блок 30 дней), meta.limit — потолок, meta.used — израсходовано, meta.reset_at — момент обнуления счётчика; повторять запрос раньше бессмысленно. Ограничение снимается переходом на тариф выше сразу после оплаты. Автосписания по подпискам, созданным через API, при действующем ограничении пропускают цикл, дата следующего списания не сдвигается. В POST /invoices/bulk отказ приходит поэлементно и несёт только error_code и message. Доставка: sync HTTP 429 + заголовок Retry-After |
| error_code: outstanding_recipient_limit | Антифрод: слишком много неоплаченных (outstanding) счетов на одного получателя. Дождитесь оплаты или истечения ранее выставленных счетов. Доставка: sync HTTP 429 + заголовок Retry-After |
| error_code: outstanding_org_limit | Антифрод: слишком много неоплаченных (outstanding) счетов по организации в целом. Дождитесь оплаты или истечения ранее выставленных счетов. Доставка: sync HTTP 429 + заголовок Retry-After |
| error_code: recipient_fanout_exceeded | Антифрод: превышен темп рассылки счетов по разным получателям (fan-out). Времени повтора в ответе нет — ожидание само по себе ограничение не снимает, причину разбирают через поддержку. Доставка: sync HTTP 429, без Retry-After и retry_after_seconds |
| error_code: content_rejected | Антифрод: содержимое счёта (например текст описания) отклонено проверкой. Исправьте текст и повторите. Доставка: sync HTTP 422 |
| error_code: description_too_long | Описание счёта длиннее, чем Kaspi показывает покупателю: видны только первые 60 символов, остальное Kaspi отбрасывает. Укоротите описание — вынесите в начало то, по чему плательщик узнает платёж. Повтор с тем же телом бесполезен. Доставка: sync HTTP 422 |
| error_code: field_too_long | Значение поля слишком длинное. Имя поля лежит ключом в errors — укоротите именно его; повтор с тем же телом бесполезен. Страховочный код: обычно слишком длинное значение отсекается обычной ошибкой валидации, форма которой не менялась. В POST /invoices/bulk приходит не на весь запрос, а строкой в invoices[] у конкретной позиции: ответ остаётся 201, соседние счета созданы, повторить нужно только эту позицию. Доставка: sync HTTP 422 |
| error_code: sandbox_simulated_error | Симулированная ошибка в песочнице: POST /invoices/{id}/simulate-status со status=error присваивает счёту этот error_code (с опциональным error_message до 255 символов) и шлёт вебхук invoice.status_changed как при реальной ошибке. Только sandbox (в проде simulate-status недоступен). Доставка: async — приходит в invoice.status_changed (invoice.error_code) |
| error_code: cart_items_mismatch | Зарезервирован — сейчас не используется бэкендом (такие случаи приходят как kaspi_error). Доставка: — |
| error_code: image_upload_failed | Зарезервирован — сейчас не используется бэкендом. Доставка: — |
| error_code: catalog_item_not_found | Зарезервирован — сейчас не используется бэкендом. Доставка: — |

### Каталог: чтение списка и ошибок (GET /catalog, GET /catalog/errors)

| Code | Description |
|------|-------------|
| catalog_batch_filter_removed (422) | Фильтр по партии (batch_id) больше не поддерживается: агрегат партий удалён. Отбивается по самому наличию параметра, поэтому пустой batch_id тоже вернёт 422 — это сделано намеренно, чтобы старый интегратор увидел отказ, а не молча получил не тот набор. Подтверждайте свой набор точечным запросом GET /catalog?external_refs[]=..., остаток работы смотрите в GET /catalog/queue, отказы — в GET /catalog/errors с окном from и to по моменту отказа, push — событие catalog.item_processed. Доставка: sync HTTP 422 |

### Каталог: резолв штрихкода (POST /catalog/scan)

| Code | Description |
|------|-------------|
| kaspi_session_expired (400) | Сессия Kaspi мерчанта истекла — нужна переавторизация кассира Kaspi. Доставка: sync HTTP 400 |
| kaspi_throttled (429) | Kaspi троттлит сессию при сканировании. Повторить после паузы (тело retry_after_seconds, заголовок Retry-After). Circuit-breaker: ~90 с сразу отдаёт 429 без обращения к Kaspi. Доставка: sync HTTP 429. Примечание: отличается от async error_code kaspi_throttled по счетам |
| kaspi_scan_unavailable (503) | Нацкаталог Kaspi временно недоступен — повторить позже. Доставка: sync HTTP 503 |

### Каталог: приём позиций (POST /catalog)

| Code | Description |
|------|-------------|
| error_code: catalog_item_invalid | Позиция не прошла валидацию: имя, цена, единица измерения или другое поле. Конкретика — в error_message и карте errors по позиции. Доставка: per-item в rejected[] ответа POST /catalog |
| error_code: catalog_item_duplicate | Kaspi отклонил товар как похожий на уже существующий в вашем каталоге. Проверьте, нет ли позиции с тем же названием и штрихкодом, — если товар действительно новый, измените название так, чтобы оно отличалось |
| error_code: barcode_too_long | Штрихкод длиннее допустимого — не более 32 символов. Обрежьте значение или передайте позицию без штрихкода |
| error_code: catalog_delivery_incomplete | Позицию не удалось довезти до Kaspi — Kaspi её ни разу не видел и данные не отвергал. Чинить в позиции нечего: переотправьте её тем же POST /catalog с тем же external_ref, данные менять не нужно. Если вы шлёте Idempotency-Key, повтор должен идти с НОВЫМ ключом, иначе ответ вернётся из кеша идемпотентности и работа по строке не откроется. Раньше этот случай приходил под кодом catalog_item_invalid. Доставка: error_code строки каталога (GET /catalog/errors, событие catalog.item_processed) |
| error_code: catalog_create_unresolved | Запрос на создание ушёл в Kaspi, но подтверждения по позиции так и не пришло, и попытки прекращены. Данные позиции ни при чём. Прежде чем заводить товар заново, посмотрите каталог в приложении Kaspi Pay: он мог всё-таки создаться, и повторное заведение даст две одинаковые позиции. Дожать строку — PATCH /catalog/{id} («Повторить» в кабинете); дословный повтор POST /catalog такую строку не возобновляет. Доставка: error_code строки каталога (GET /catalog/errors, событие catalog.item_processed) |
| error_code: catalog_create_blocked | Записывать каталог от имени организации было невозможно слишком долго — например, подключение кассира перестало быть активным, — и заявка на создание закрыта по сроку. Данные позиции ни при чём: сначала восстановите подключение, затем дожмите строку через PATCH /catalog/{id} («Повторить» в кабинете). Дословный повтор POST /catalog такую строку не возобновляет. Доставка: error_code строки каталога (GET /catalog/errors, событие catalog.item_processed) |
| error_code: catalog_delete_abandoned | Удаление позиции не удалось довести до конца: установить, что стало с позицией в каталоге Kaspi, не получилось. Обратитесь в поддержку. Повторный DELETE /catalog/{id} запускает проверку заново |

### Каталог: массовое удаление (POST /catalog/bulk-delete)

| Code | Description |
|------|-------------|
| catalog_delete_scope_required (422) | Не задан ни ids[], ни external_refs[], либо заданы оба списка сразу. Передайте ровно один список — сервер не додумывает, что именно вы хотите удалить |
| catalog_match_overflow (422) | В списке слишком много значений — за один запрос принимается не более 200 ids или external_refs. Разбейте выборку на части |
| catalog_bulk_delete_mismatch (409) | Переданный expected_count разошёлся с фактом: каталог изменился между разведкой и командой. В теле придёт actual_count — повторите dry_run и убедитесь, что удаляете то, что хотели. Поле expected_count необязательное, но при расхождении не удаляется ничего |
| catalog_multi_tradepoint (409) | У организации несколько торговых точек — массовое удаление для неё закрыто. Обратитесь в поддержку. У одиночного DELETE /catalog/{id} этот же код синхронно больше не приходит: запрос принимается с 202, а код появляется на самой позиции в error_code |
| catalog_busy (409) | Каталог занят другой операцией — повторите запрос через несколько секунд |
| idempotency_key_conflict (409) | Переданный Idempotency-Key уже занят другим телом либо другой каталожной операцией — пространство ключей общее. Возьмите новый ключ. Точный повтор ТОГО ЖЕ тела с тем же ключом конфликтом не считается: приходит 200 с idempotent_replay true, и ничего не удаляется повторно |
| request_rate_limited (429) | Превышен лимит эндпоинта — 10 запросов в минуту. Разведка (dry_run) расходует тот же лимит. Пауза — в поле retry_after_seconds и в заголовке Retry-After |

### Каталог: изменение и снятие позиции (PATCH / DELETE /catalog/{id})

| Code | Description |
|------|-------------|
| catalog_item_foreign_channel (409) | Позиция заведена другой интеграцией этой организации: изменить и снять её можно только со стороны той интеграции либо из кабинета мерчанта. Отказ именно 409, а не 404, потому что позиция видна: в списке она приходит с source: other. ⚠️ Повторная заливка ошибки не даёт: POST /catalog при совпадении external_ref, barcode или ntin вернёт её id с matched_existing: true и НЕ обновит ни имя, ни цену — прежде чем считать позицию синхронизированной, сверяйте source. Позиций, у которых интеграции-автора нет (source: shared), это не касается. |

### Тариф: индивидуальные условия

| Code | Description |
|------|-------------|
| custom_tariff_locked (409) | У мерчанта индивидуальные условия тарифа: смена тарифа не самообслуживаемая и оформляется поддержкой. Продление ТОГО ЖЕ тарифа не блокируется — это оплата, а не смена условий. Определяйте состояние заранее: is_custom в GET /api/v1/tariff и can_change_tier в каталогах планов; повтор запроса не поможет |

### Каталог: паритет has_catalog ↔ cart_items

| Code | Description |
|------|-------------|
| error_code: catalog_requires_cart_items | У организации включён каталог товаров: счёт должен нести состав покупки. Передайте cart_items[] — идентификаторы позиций берутся из GET /catalog, цена строки при необходимости переопределяется полем price. Приходит на POST /invoices/qr и POST /static-qr. У подписки (POST /subscriptions) этого кода нет: она принимает amount без корзины, а без amount и без cart_items отвечает 422 по полю amount. Доставка: sync HTTP 422 (без ключа errors) |
| error_code: catalog_not_supported | У организации каталог товаров выключен, а в запросе передана корзина. Уберите cart_items[] и передайте amount. Если каталог вам нужен — он включается вместе с Kaspi Кассой (ОФД), напишите нам. Доставка: sync HTTP 422 (без ключа errors), а в POST /invoices/bulk — per-item в разборе позиций |

### Подключение кассира (POST /connections/{connection}/auth/*)

| Code | Description |
|------|-------------|
| duplicate_cashier_confirm (409) | Этот номер кассира уже подключён к другой организации того же владельца. Это не блокировка: повторите тот же запрос send-phone с confirm_duplicate: true. В теле — can_override и existing_organization с полями id, name, connection_status и invoices_count; connection_status относится к подключению, а не к организации |
| entrance_auth_disabled (503) | Подключение кассира временно недоступно — идут технические работы. Повторите позже, данные подключения не пострадали |
| cashier_change_requires_merchant (403) | Сменить кассира у этой организации может только сам мерчант. Отказ приходит на send-phone, когда ключ выдан партнёром на организацию, аккаунтом которой партнёр не владеет, и присланный номер отличается от телефона текущего кассира. Первое подключение (кассира ещё нет) и переавторизация того же кассира проходят. Повтор не поможет: смену проводит мерчант — своей дверью либо по ссылке-приглашению. |
| context_expired (409) | Процесс авторизации кассира закрыт: контекст истёк либо Kaspi его потерял. Исход терминальный — повторять send-phone или verify-otp бесполезно, начните заново с POST /connections/{connection}/auth/init. Уже подтверждённая ранее привязка не сбрасывается. |
| kaspi_busy (503) | Kaspi временно не принимает этот шаг. Приходит на шагах send-phone и verify-otp; в заголовке Retry-After — сколько секунд ждать. Сессия авторизации при этом закрыта: после паузы нужен новый POST /connections/{connection}/auth/init, а не повтор того же шага. |
| sms_failed (502) | Kaspi не отправил кассиру SMS с кодом. Шаг send-phone можно повторить. Если отказ повторяется, проверьте, что номер заведён кассиром в Kaspi Pay. |
| kyc_required (403) | Анкета «Расскажите о бизнесе» ещё не одобрена — подключить кассира можно после одобрения. На init и send-phone SMS кассиру не отправляется вовсе; на verify-otp отказ терминальный (одобрение перестало действовать между шагами): Kaspi код уже принял, но подключение не создаётся — после одобрения нужен новый init. Повтор до одобрения бесполезен: решение принимает человек, Retry-After не отдаётся. Переподключение уже привязанного кассира (тот же номер) этим кодом не отбивается. Доставка: sync HTTP 403 |

### Подключение кассира: сверка организации Kaspi

| Code | Description |
|------|-------------|
| organization_identity_conflict (409) | Подключаемый кассир принадлежит другой организации Kaspi. Организация закрепляется за первой привязкой и подключением нового кассира не переносится. Подключайте кассира той организации, за которой эта уже закреплена, либо заводите отдельную организацию. В теле приходят только error и message: идентификаторов и названия другой организации в ответе нет. Если владелец бизнеса действительно сменился, напишите в поддержку 77003076512. Доставка: sync HTTP 409 |
| organization_identity_unavailable (502) | Kaspi не вернул достоверные данные организации, и сверить её в этой попытке не удалось. Попытка входа закрыта: начните заново с POST /connections/{connection}/auth/init, повторная отправка кода на закрытом процессе вернёт no_process. Уже подтверждённая ранее привязка не сбрасывается и не блокируется. В теле приходят только error и message, отдельного поля error_code нет — разбирайте error. Доставка: sync HTTP 502 |
| connection_identity_unverified (422) | Основным нельзя назначить подключение, которое ещё не подтвердило организацию или заблокировано. Проведите вход кассира до конца и повторите. В теле приходят только error и message, отдельного поля error_code нет — разбирайте error. Доставка: sync HTTP 422 |

### KYC: верификация бизнеса и webhook-домен (новое)

| Code | Description |
|------|-------------|
| error_code: kyc_daily_limit_reached | Молодая организация: пока анкета о бизнесе не одобрена, боевые счета не выставляются вовсе — meta.limit = 0 (песочница не затронута, интеграцию можно полностью отладить там). Чтобы снять ограничение — заполните короткую анкету «Расскажите о бизнесе» в кабинете (/business-profile), одобрение обычно за несколько часов. Повторять запрос до одобрения бесполезно. Доставка: sync HTTP 429 (meta.reset_at — когда лимит сбросится, meta.kyc_status — текущий статус) |
| error_code: kyc_rejected | Приём платежей недоступен по итогам проверки бизнеса (статус организации blocked). Не повторяемая — напишите в поддержку, если считаете это ошибкой. Доставка: sync HTTP 403 при создании счёта (POST /invoices, POST /invoices/qr) |
| error_code: webhook_url_requires_domain | Адрес webhook должен быть на вашем домене — IP-адреса не принимаются. Действует для ещё не одобренных организаций в рабочем режиме; в песочнице правило мягче. Укажите публичный HTTPS-адрес на домене. Доставка: sync HTTP 422 при сохранении webhook-URL |
| error_code: webhook_url_tunnel_forbidden | Туннели (ngrok и подобные) нельзя использовать для рабочих webhook — они временны и отключатся, уведомления перестанут приходить. Укажите адрес на вашем домене. Действует для ещё не одобренных организаций в рабочем режиме; в песочнице туннель для локального теста допустим. Доставка: sync HTTP 422 при сохранении webhook-URL |

### Фискальные чеки (Kaspi OFD)

| Code | Description |
|------|-------------|
| error_code: fiscal_receipts_disabled (403) | Исторический код: с 06.09.2026 не приходит. Может встретиться в старых логах и записях доставки вебхуков. Ветку обработки можно оставить — она больше не срабатывает |
| error_code: not_sandbox (403) | Поле simulate прислано боевой организацией — форсировать исход чека можно только в песочнице. Чек не создан. Уберите simulate из тела запроса. Доставка: sync HTTP 403 (POST /receipts) |
| error_code: kaspi_session_not_configured (409) | К организации не привязан кассир Kaspi Pay — выбить чек не через кого. Подключите кассира в кабинете (Настройки → Подключение кассира). Доставка: sync HTTP 409 |
| error_code: duplicate_client_operation_id (409) | Чек с таким client_operation_id уже выбивался — второй раз он не пробьётся (идемпотентность). В теле ответа приходит receipt_id существующего чека: опросите его через GET /receipts/{id}. Повтор после failed разрешён, но с НОВЫМ ключом. Доставка: sync HTTP 409 |
| error_code: connection_ambiguous (422) | К организации привязано несколько кассиров — непонятно, через какого выбивать чек. Передайте kaspi_connection_id явно. Доставка: sync HTTP 422 |
| error_code: receipt_preview_unavailable | Предпросмотр чека временно недоступен (POST /receipts/preview). Повторяемая — попробуйте позже; на выбивание самого чека не влияет. Доставка: sync HTTP 503 |
| error_code: receipt_not_found (404) | Чек не найден или принадлежит другой организации. Доставка: sync HTTP 404 (GET /receipts/{id}) |
| error_code: shift_closed | Смена на кассе закрыта — чек выбить нельзя. Откройте смену в приложении Kaspi Pos и повторите с новым client_operation_id. В песочнице воспроизводится через simulate. Доставка: async, status=failed (GET /receipts/{id}, вебхук receipt.failed) |
| error_code: item_not_fiscal | Позицию нельзя выбить в чек: её нет в каталоге организации; в бою она ещё не синхронизирована с Kaspi (in_kaspi_catalog=false — в песочнице это поле всегда false, и чек выбивается); или у неё есть штрихкод, но нет НТИН (ntin_missing=true) — дорезолвите НТИН через POST /catalog/scan и сохраните его в позиции (PATCH /catalog/{id}) и повторите чек. Несинхронизированную позицию повторите, когда in_kaspi_catalog станет true (GET /catalog или вебхук catalog.item_processed). Позиция без НТИН и без штрихкода (например, услуга) этим кодом не отказывает — она выбивается в чек без маркировки Нацкаталога. Доставка: async, status=failed |
| error_code: receipt_vat_not_supported | В чеке есть позиция со ставкой НДС — такие чеки не выбиваются. Фискальный документ не создан. Уберите позицию со ставкой НДС из состава чека и выбейте чек заново. Не повторяемая: тот же состав чека вернёт тот же отказ. Доставка: async, status=failed (GET /receipts/{id}, вебхук receipt.failed) |
| error_code: rfo_missing | Не определён код торговой точки — Kaspi не может зарегистрировать чек. Подключите Kaspi Kassa (ОФД) к точке в приложении Kaspi Pay, затем переподключите кассира в ApiPay. Доставка: async, status=failed |
| error_code: receipt_kaspi_error | Kaspi отклонил выбивание чека; подробности — в поле error_message чека. Повтор возможен с НОВЫМ client_operation_id. В песочнице воспроизводится через simulate. Доставка: async, status=failed |
| error_code: receipt_dispatch_error | Не удалось отправить чек в Kaspi (сеть или временный сбой). Повторяемая — попробуйте ещё раз с новым client_operation_id. Доставка: async, status=failed |
| error_code: receipt_ofd_token_revoked | Фискальная привязка кассы отозвана — мерчанту нужно перепривязать ОФД в приложении Kaspi. Приём оплат при этом работает: счета и QR продолжают создаваться, встают только чек и изменяющий каталог. Не путайте с kaspi_session_invalid — платёжная сессия жива, переподключение кассира не поможет. Повтор имеет смысл только ПОСЛЕ перепривязки ОФД, временем не лечится. Доставка: async, status=failed |

### Чек Kaspi по счёту (GET /invoices/{id}/receipt)

| Code | Description |
|------|-------------|
| error_code: receipt_not_available_for_status (409) | Чек есть только у оплаченного или частично возвращённого счёта. Проверьте status счёта перед запросом. Тот же код приходит, если у оплаченного счёта ещё нет числового идентификатора Kaspi — чек по такому счёту получить нельзя. Не повторяемая. Доставка: sync HTTP 409 |
| error_code: kaspi_session_expired (409) | Кассир, через которого прошла оплата, требует переподключения — чек получить нельзя, пока кассир не подключён заново. Счёт и оплата не затронуты. Доставка: sync HTTP 409 |
| error_code: kaspi_session_unavailable (409) | Кассир по счёту сейчас недоступен — повторите позже; если повторяется, проверьте подключение кассира в кабинете. Доставка: sync HTTP 409 |
| error_code: receipt_rate_limited (429) | Запросов чеков слишком много — у ручки отдельный лимит. Повторите через минуту. Доставка: sync HTTP 429 |
| error_code: receipt_unavailable (503) | Чек получить не удалось. Повторяемая — попробуйте через минуту. Счёт и оплата не затронуты. Доставка: sync HTTP 503 |

### Возврат по QR (клиент подтверждает сканированием)

| Code | Description |
|------|-------------|
| qr_refund_not_identified (409) | Покупатель ещё не отсканировал возвратный QR. Приходит на GET /qr-refunds/{id}/operations и на execute. Не ошибка интеграции — дождитесь статуса customer_identified (поллинг GET /qr-refunds/{id} или вебхук qr_refund.identified). Сессия жива. |
| qr_refund_expired (409) | Срок сессии истёк. У сессии ДВА срока: окно скана QR — не более 90 секунд (expires_at), и отдельно ограничено время на выбор операции после подтверждения покупателем. Возврат не сделан — начните новую сессию. У ссылки «Возврат ApiPay» дополнительно действует link_expires_at (24 часа); пропущенное окно скана её не завершает — покупатель может открыть новое окно той же ссылкой. Не повторяемая. |
| qr_refund_completed (409) | Возврат по этой сессии уже выполнен. Повторный execute не пройдёт (идемпотентность). Не считайте это отказом: запросите GET /qr-refunds/{id} и возьмите refunded_amount и receipt_url оттуда. |
| operation_not_returnable (422) | Kaspi не разрешает возврат по выбранной операции (returnable: none) — срок возврата истёк либо деньги уже возвращены. Сессия остаётся живой: выберите другую покупку. |
| refund_amount_exceeds_available (422) | Сумма больше доступной к возврату. Актуальное значение — в available_for_refund из GET /qr-refunds/{id}/operations/{ref}. Сессия жива, можно повторить с корректной суммой. |
| refund_insufficient_funds (422) | На счёте Kaspi Pay мерчанта не хватает денег на возврат. Отказ приходит ДО отправки денег: ничего не списано, сессия остаётся customer_identified. Пополните счёт Kaspi Pay — и повторите возврат на той же сессии, пока не истёк срок идентификации покупателя; после него понадобится новая ссылка. Второй выход — вернуть покупателю наличными. Раньше этот отказ приходил как 502 kaspi_error, и повтор без пополнения счёта давал тот же результат. Тот же код приходит асинхронно в refund.error_code вебхука invoice.refunded (status=failed) для POST /invoices/{id}/refund. |
| partial_refund_requires_return_items (422) | Эту покупку Kaspi возвращает только по позициям: пришлите items вместо amount. Напоминание: amount и items взаимоисключимы. |
| not_found (404) | Сессия не найдена или принадлежит другой организации — код ответа одинаков в обоих случаях (не оракул чужих сессий). Начните новую сессию. |
| not_sandbox (403) | Поле simulate или POST /qr-refunds/{id}/simulate прислан боевой организацией — форсировать шаги покупателя можно только в песочнице. |
| qr_refund_execution_uncertain (202 своему запросу / 409 повтору) | ⛔ Самый важный код группы. Денежный запрос ушёл, а ответ Kaspi не доказал ни успех, ни отказ — возврат мог быть применён. Своему запросу приходит 202 со снимком сессии в статусе execution_uncertain, повтору того же execute — 409. Сессия терминальна для автоматики. Повторять execute НЕЛЬЗЯ ни при каких условиях: второй запрос — это второй возврат живых денег покупателю. Retry-After не отдаётся. Разбирается вручную — обратитесь в поддержку. До ответа поддержки не проводите этот возврат ни повторным execute, ни вручную в приложении Kaspi Pay — деньги могли уже уйти. Параллельно приходит вебхук qr_refund.execution_uncertain. Доставка: sync HTTP 202 и HTTP 409 (POST /qr-refunds/{id}/execute) |
| qr_refund_execution_result_unavailable (202) | ⛔ Денежный запрос ушёл, а его исход не доказан — деньги могли уйти. Снимка сессии в этом ответе нет: не ждите её полей и не выводите статус из их отсутствия. Повторять execute НЕЛЬЗЯ: попытка уже потрачена. Retry-After не отдаётся. Разбирается вручную — обратитесь в поддержку. Доставка: sync HTTP 202 (POST /qr-refunds/{id}/execute) |
| qr_refund_execution_in_progress (409) | ⛔ Возврат по этой сессии уже идёт: его начал предыдущий запрос. Сам этот 409 денег не двигает, но повторять execute НЕЛЬЗЯ — второй запрос рискует стать вторым возвратом. Ретрай на этот код не строить. Дождитесь терминального состояния через GET /qr-refunds/{id} или вебхук. Доставка: sync HTTP 409 (POST /qr-refunds/{id}/execute) |
| qr_refund_execution_attempts_exhausted (409) | Число денежных попыток по одной сессии возврата исчерпано. Kaspi при этом не вызывается вовсе: деньги не двигались, сессия остаётся customer_identified. Повторять execute на этой сессии бесполезно — выпустите новую ссылку возврата и получите новое подтверждение покупателя. Автоповтор execute не зацикливайте: на 422 refund_insufficient_funds и 502 kaspi_error повтор имеет смысл по действию человека (например, после пополнения счёта Kaspi Pay), но окно подтверждения покупателя короткое: на той же сессии успевает лишь тот, у кого деньги появляются сразу, — иначе тоже новая сессия. А на этом коде — только новая сессия. Доставка: sync HTTP 409 (POST /qr-refunds/{id}/execute) |
| kaspi_error (502) | Kaspi недоступен либо ответил неразбираемо до отправки денег. Деньги не двигались, сессия остаётся customer_identified. Повтор здесь допустим: попробуйте ещё раз, пока жива сессия. Доставка: sync HTTP 502 (POST /qr-refunds/{id}/execute) |
| qr_refund_execution_context_unavailable (503) | Условия возврата не удалось проверить достоверно, поэтому денег мы не трогали: ни одного вызова Kaspi не сделано. Сессия остаётся customer_identified и жива. Повторить execute можно — попробуйте позже, пока не истёк срок сессии. Доставка: sync HTTP 503 (POST /qr-refunds/{id}/execute) |
| qr_refund_execution_disabled (503) | Денежное выполнение возвратов сейчас закрыто. Отказ происходит до отправки денег: ни одного вызова Kaspi не сделано, деньги не двигались, сессия остаётся customer_identified. Повторить execute можно позже; если код держится, обратитесь в поддержку. Доставка: sync HTTP 503 (POST /qr-refunds/{id}/execute) |
| qr_refund_actor_no_longer_authorized (403) | Доступ, которым начат возврат, к моменту отправки денег уже не действует: ключ отозван или просрочен, канал закрыт, доступ к организации потерян. Это отдельный код, а не tariff_inactive: у организации право может быть в полном порядке, вопрос к конкретному ключу. Отказ до отправки денег — деньги не двигались, сессия жива. Перевыпустите ключ или восстановите права и повторите execute. Доставка: sync HTTP 403 (POST /qr-refunds/{id}/execute) |
| qr_refund_temporarily_unavailable (503) | Операция временно недоступна: приходит на выпуск ссылки POST /qr-refunds/links, на её отзыв и на старт POST /qr-refunds. Ни одного вызова Kaspi не сделано, ничего не создано и не списано. Повторить можно позже. Доставка: sync HTTP 503 |
| qr_refund_link_limit_reached (429) | У организации уже максимум неиспользованных ссылок на возврат — до 20 одновременно. Ссылка не создана, Kaspi не вызывался, ничего не потрачено. Дождитесь, пока покупатели откроют выпущенные ссылки, либо отзовите лишние через DELETE /qr-refunds/links/{id} — и повторите выпуск. Доставка: sync HTTP 429 (POST /qr-refunds/links) |
| qr_refund_link_not_revocable (409) | Отозвать нечего: покупатель уже открыл ссылку либо она терминальна по другой причине. Это не 404 — ссылка существует и принадлежит вам. Повтор отзыва результата не изменит; текущее состояние читайте через GET /qr-refunds/{id}. Активированная сессия завершится сама по своему окну скана. Доставка: sync HTTP 409 (DELETE /qr-refunds/links/{id}) |
| qr_refund_link_invoice_busy (409) | По счёту уже выполняется обычный или QR-возврат, включая неизвестный денежный результат. Новый возврат не создан. Дождитесь результата. Неактивированную ссылку можно явно отозвать, если сервер допускает отзыв. Доставка: HTTP 409, POST /invoices/{id}/refund и POST /qr-refunds/links. |
| invoice_not_refundable (422) | Этот счёт нельзя вернуть по ссылке: он не найден, принадлежит не вашей организации, не оплачен, уже возвращён целиком или остатка к возврату по нему нет — причины снаружи намеренно не различаются. Ссылка не создана, Kaspi не вызывался, деньги не двигались. Повтор с тем же invoice_id результата не изменит: проверьте id счёта и его состояние через GET /invoices/{id}, а если возврат всё равно нужен — выпустите ссылку без привязки к счёту. Тот же код приходит и в вебхуке qr_refund.failed, если счёт стал недоступен к возврату к моменту автопроведения; тогда нужна новая ссылка. Доставка: sync HTTP 422 (POST /qr-refunds/links), вебхук qr_refund.failed |
| error_code: qr_refund_operation_not_found | Оплата этого счёта не найдена среди операций покупателя, который подтвердил себя по ссылке: например, код отсканировал другой человек. HTTP-отказом не приходит: это пара error_code/error_message в снимке сессии, статус сессии — failed, приходит вебхук qr_refund.failed после qr_refund.identified. Возврат не сделан, деньги не двигались. Повторять нечего — выпустите новую ссылку и убедитесь, что её открывает и сканирует сам покупатель, которому возвращаете деньги. Доставка: GET /qr-refunds/{id}, вебхук qr_refund.failed |
| error_code: qr_refund_link_revoked | Продавец сам отозвал ссылку на возврат через DELETE /qr-refunds/links/{id}. HTTP-отказом не приходит: это пара error_code/error_message в снимке сессии, статус сессии — expired. Возврат не сделан и по этой ссылке уже не будет. Нужен возврат — выпустите новую ссылку. Доставка: GET /qr-refunds/{id}, ответ DELETE /qr-refunds/links/{id} |
| error_code: qr_refund_link_expired | Неактивированная ссылка не была использована до своего срока: link_expires_at — 24 часа с выпуска, и он не продлевается ничем, ни открытием страницы, ни перезагрузкой. HTTP-отказом не приходит: пара error_code/error_message в снимке сессии, статус — expired. Возврат не сделан, Kaspi не вызывался. Выпустите новую ссылку. Доставка: GET /qr-refunds/{id} |
| qr_return_scan_timeout (409) | Окно скана закрылось раньше, чем возвратный QR был выдан покупателю. Окно — не более 90 секунд, действующее значение приходит в scan_wait_timeout_seconds. Возврат не сделан, деньги не двигались, сессия терминальна. Повторять тот же запрос нечем — выпустите новую ссылку POST /qr-refunds/links. Доставка: sync HTTP 409, а также error_code в снимке сессии |
| error_code: qr_return_identity_timeout | Покупатель подтверждение прошёл, но возврат не был выполнен в отведённое время — здесь не успел продавец, в отличие от qr_return_scan_timeout. HTTP-отказом не приходит: пара error_code/error_message в снимке сессии, статус — expired. Деньги не двигались. Начните возврат заново — новой ссылкой. Доставка: GET /qr-refunds/{id}, вебхук qr_refund.expired |
| error_code: qr_return_not_found | Kaspi больше не видит эту операцию возврата. HTTP-отказом не приходит: пара error_code/error_message в снимке сессии, статус — failed. Возврат не сделан, деньги не двигались. Повторять нечего — создайте новую ссылку на возврат. Доставка: GET /qr-refunds/{id}, вебхук qr_refund.failed |
| qr_refund_activation_in_progress (202) | Запрос на выдачу возвратного QR принят и уже ушёл, а исход ещё не записан. Это не ошибка: единственная попытка активации потрачена. Повторять запрос нельзя — ни ссылка, ни старт второй попытки не делают. Состояние читайте через GET /qr-refunds/{id} или ждите вебхук. Доставка: sync HTTP 202 (POST /qr-refunds) |
| qr_refund_activation_failed (502) | Начать подтверждение не удалось: возвратный QR выдан не был. Статус сессии — failed, возврат не начинался и деньги не двигались. Попытка потрачена: повторять тот же запрос нельзя, нужна НОВАЯ сессия — новая ссылка POST /qr-refunds/links либо новый POST /qr-refunds. Доставка: sync HTTP 502, вебхук qr_refund.failed |
| organization_not_verified (400) | Организация не верифицирована — возврат по QR ей недоступен. Код приходит на POST /qr-refunds, POST /qr-refunds/links и POST /qr-refunds/{id}/execute. Отказ происходит до обращения в Kaspi: деньги не двигались. Пройдите верификацию в кабинете и повторите. |

### Касса: смены, наличные, сверка (/cashbox/*)

| Code | Description |
|------|-------------|
| cashbox_disabled (403) | Кассовые операции для организации сейчас недоступны. В песочнице этот отказ не приходит. |
| cashbox_kkm_unknown (409) | Номер кассы (ККМ) для организации неизвестен. Он есть только у организаций с подключённой кассой Kaspi (ОФД) — той же, что включает каталог товаров. Если продажи идут через Kaspi Pos без ОФД, ждать нечего: кассовых смен у такой организации не существует. Если Kaspi Касса подключена, проверьте, что кассир подключён и его сессия активна; при нескольких кассах укажите kaspi_connection_id нужной точки. Если код продолжает приходить — напишите в поддержку. |
| rfo_missing (409) | Код торговой точки Kaspi не определён: этим кодом отвечают GET /cashbox/summary и оба тумблера настроек. Чаще всего это значит, что к аккаунту Kaspi Pay не подключена касса Kaspi (ОФД) — тогда кассовых смен у организации не существует. Если Kaspi Касса подключена, при нескольких кассах передавайте kaspi_connection_id нужной точки. Код торговой точки ApiPay получает при подключении кассира. Если Kaspi Кассу подключили позже, переподключите кассира или нажмите в настройках кабинета «Обновить информацию об организации». ⚠️ Оба действия могут включить каталог товаров: после него POST /invoices/qr и POST /static-qr без cart_items отвечают 422 catalog_requires_cart_items, а напечатанные QR-листы без состава перестают работать. Тот же код приходит и в фискальном контуре, по той же причине: чек не выбивается, пока код торговой точки не определён. |
| cashbox_no_open_shift (см. operation.error_code) | Открытой смены на кассе нет — закрывать нечего. |
| cashbox_shift_already_closed | Смена уже закрыта. Для операции закрытия это успешный исход: целевое состояние достигнуто. |
| cashbox_shift_not_found (404) | Смена с таким id недоступна. Возьмите id из GET /cashbox/shifts. |
| cashbox_operation_not_found (404) | Операция не найдена или принадлежит другой организации — код ответа одинаков в обоих случаях. |
| cashbox_duplicate_operation (409) | client_operation_id уже использован. В теле ответа приходит operation_id принятой операции — по нему продолжайте поллинг; для нового закрытия возьмите новый ключ. Ключ не освобождается даже после failed. |
| cashbox_busy (см. operation.error_code) | Касса занята другой операцией. Повторите позже новым client_operation_id. |
| cashbox_operation_failed (см. operation.error_code) | Kaspi не выполнил операцию. При resolution.safe_to_retry=false автоматический повтор небезопасен — решение оставьте человеку. |
| cashbox_unavailable (503) | Касса Kaspi временно недоступна, данные получить не удалось. Повторите позже. |
| cashbox_report_unavailable (503) | Отчёт по смене получить не удалось. Повторите позже. |
| cashbox_toggle_in_progress (503) | Переключение тумблера уже выполняется. Повторите позже. |
| cashbox_toggle_unavailable (503) | Текущее значение на кассе проверить не удалось — переключение не выполнено. Повторите позже. |
| cashbox_settings_owner_key_required (403) | Тумблеры настроек кассы переключает только ключ, выпущенный владельцем организации: PUT /cashbox/settings/auto-close и PUT /cashbox/settings/auto-withdrawal. Ключ сотрудника получает этот отказ, и повтор не поможет — перевыпустите ключ от имени владельца. Чтение GET /cashbox/settings доступно любому ключу организации. |


---

## Changelog

- **2026-10-03** [NEW] Пропуск периода подписки через API: `POST /subscriptions/{id}/skip-period/preview` и `POST /subscriptions/{id}/skip-period`. Действие закрывает период без оплаты — например, когда покупатель заплатил вам другим способом; подписка остаётся активной. Превью ничего не меняет и отвечает объектом `skip`: `kind` (`live_invoice` — счёт периода уже выставлен, пропуск запросит его отмену в Kaspi; `open_retry` — период не оплачен и ещё открыт; `next_period` — ближайший период, счёт за который ещё не выставлялся), `billing_period_start`, `billing_period_end`, `invoice` (`id`, `amount`, `status` или `null`), `next_billing_at` с `next_billing_label` и `next_billing_in_days`, `resets_retries`. Действие принимает обязательное `billing_period_start` из превью и отвечает `200 {message, subscription, skip}`; у `skip` есть ещё `replayed`. Отказы — `409` с `error`/`error_code`: `subscription_not_active` (подписка не `active`), `subscription_cycles_exhausted` (получены все оплаты `total_cycles`), `subscription_busy` (по подписке прямо сейчас выставляется счёт — повторите через минуту), `skip_period_changed` (период сменился, в теле свежий `skip`); без `billing_period_start` — `422`; при неоплаченном тарифе — `403 tariff_inactive`. Что делать: сначала вызовите превью и покажите человеку, какой период будет пропущен, затем вызовите действие с `billing_period_start` из превью. Повтор с тем же значением безопасен: второй период не пропустится, ответ придёт с `replayed: true`. На `skip_period_changed` не повторяйте вслепую — покажите свежий `skip` и подтвердите заново. Новые `error_code`: `subscription_not_active`, `subscription_cycles_exhausted`, `subscription_busy`, `skip_period_changed`.
- **2026-10-03** [NEW] Пропуск периода подписки: статус `skipped` в истории платежей и вебхук `subscription.period_skipped`. Продавец может закрыть период подписки без оплаты — в кабинете ApiPay или через API (см. запись выше). Подписка остаётся активной: попытки и льготный период сбрасываются, следующий период выставляется по расписанию. В истории платежей (`GET /subscriptions/{id}/invoices`) у такого периода статус `skipped` — это не долг. Если счёт за период ещё не выставлялся, у строки нет счёта: `invoice_id` и `amount` равны `null`, поля `invoice` нет. В `stats` подписки (`GET /subscriptions/{id}`) пропуски не считаются. Если счёт за период уже выставлен, ApiPay отправляет его отмену в Kaspi — так же, как `POST /invoices/{id}/cancel`: итог придёт `invoice.status_changed` (`cancelled` или `error`), а если Kaspi отмену не примет, счёт без отдельного вебхука вернётся в `pending` и останется доступен покупателю. `subscription.payment_failed` по этому счёту не придёт. Вебхук `subscription.period_skipped` несёт в корне `billing_period_start`, `billing_period_end` и `cancelled_invoice_id` (счёт, отмену которого запросил пропуск; `null`, если отменять было нечего); причину пропуска событие не несёт; ключ дедупликации — `(event, subscription.id, billing_period_start)`. Пропуск не считается оплатой: подписка с `total_cycles` проработает на период дольше. Если покупатель оплатит счёт пропущенного периода — до отмены или после отказа Kaspi в ней, — период засчитывается оплаченным: строка станет `paid`, придёт `subscription.payment_succeeded` с `invoice_id`, равным `cancelled_invoice_id` из `subscription.period_skipped`. События могут прийти в любом порядке — итог периода `paid`. Если покупатель уже рассчитался с вами другим способом, лишнюю оплату верните сами через `POST /invoices/{id}/refund`. Что делать: если вы разбираете статусы платежей подписки, добавьте `skipped` и не считайте такой период долгом; если ведёте учёт периодов у себя — обрабатывайте `subscription.period_skipped`.
- **2026-10-02** [CHANGED] Потолок `POST /invoices/qr` поднят с 60 до 200 QR-запросов в минуту на организацию. Счётчик по-прежнему общий на организацию (все её ключи и печатные листы вместе), окно — календарная минута; при превышении — `429 qr_rate_limit` без `Retry-After` и без `retry_after_seconds`, повторяйте примерно через минуту. Общий лимит 200 запросов в минуту на API-ключ не изменился, поэтому с одного ключа больше 200 QR в минуту не создать. Что делать: ничего; нагрузочные прогоны делайте в песочнице. Новых `error_code`, полей, роутов и вебхук-событий нет.
- **2026-10-02** [CHANGED] Страница ссылки на оплату и печатного листа сама следит за QR. Касается `payment_url` и `print_url` из `POST /static-qr` и `POST /invoices/qr` со `static: true`. Первый QR, как и прежде, выпускается только нажатием «Оплатить». Дальше открытая страница сама перевыпускает истёкший QR, который не сканировали, — без нажатия и без повторного перехода в Kaspi. Отсканированный или открытый в приложении QR страница прячет и пишет «Оплатите в приложении Kaspi» с кнопкой «Выпустить новый QR» — теперь и у многоразового листа. Число автоматических перевыпусков подряд ограничено, после этого снова появляется кнопка. Каждый перевыпуск — новый QR-счёт этой ссылки, вебхуки `invoice.*` по нему приходят как обычно. Что делать: ничего; если вы отправляете покупателю картинку QR (`qr_image_url`), а не ссылку, — переходите на `payment_url`: картинка живёт столько же, сколько QR, а страница обновляет его сама. Новых полей ответа, эндпоинтов, `error_code` и вебхук-событий нет.
- **2026-09-27** [NEW] Необязательное поле buyer_bin — ИИН/БИН покупателя в счёте. Принимается в POST /invoices, POST /invoices/qr, в каждой строке POST /invoices/bulk и в подписках (POST /subscriptions, PUT /subscriptions/{id} — номер уходит в каждый счёт подписки; в PUT без ключа не меняется, null стирает). Формат — строка ровно из 12 цифр (числом не передавайте: потеряется ведущий ноль); иначе 422 с errors.buyer_bin. Передаётся в Kaspi для фискального чека; Kaspi сам номер не проверяет, поэтому проверьте его до отправки. Печатному листу (POST /static-qr, static: true) номер не задаётся — покупатель при печати неизвестен. Номер возвращается в ответе создания, в GET /invoices, GET /invoices/{id} и в ресурсе подписки; в вебхуках его нет. Что делать: ничего, если номер покупателя вам не нужен. Новых error_code, эндпоинтов и вебхук-событий нет.
- **2026-09-27** [CHANGED] В песочнице чек теперь открывается тестовой страницей ApiPay в браузере. Ссылки чека GET /invoices/{id}/receipt (receipt_link, share_link) и ссылка фискального чека link (POST /receipts, GET /receipts/{id}, вебхук receipt.issued) ведут на тестовую страницу чека ApiPay, а download_link — на её PDF. Страница выглядит как чек Kaspi: сумма, дата, номер и позиции берутся из вашего счёта или чека, реквизиты продавца, кассы и ОФД заменены на «Тест тест», и на ней написано, что в рабочей версии здесь будет реальный чек Kaspi. В рабочем режиме ссылки по-прежнему ведут на receipt.kaspi.kz; песочные чеки, выбитые раньше, сохранили прежнюю ссылку. Что делать: ничего; для проверки открывайте ссылку из ответа как есть и не завязывайтесь на хост или путь песочных ссылок. Форма ответов, поля, error_code и вебхук-события не менялись.
- **2026-09-26** [CHANGED] В песочнице `qr_token_url` QR-счёта ведёт на тестовую страницу ApiPay, а не в Kaspi. У счетов с `is_sandbox: true` (`POST /invoices/qr` и счета статического QR в песочнице) адрес имеет вид `https://qr.apipay.kz/sandbox/…`, и `qr_image_url` кодирует тот же адрес. Страница показывает сумму, описание и статус счёта и объясняет, что счёт тестовый и оплатить его нельзя. У счетов рабочего режима поле по-прежнему ведёт в Kaspi. Что делать: ничего; если вы проверяете, что `qr_token_url` ведёт на домен Kaspi, делайте это только для счетов рабочего режима, а путь адреса не разбирайте — его формат не входит в контракт. Новых кодов ошибок, полей, роутов и вебхук-событий нет.
- **2026-09-26** [CHANGED] `payment_url` в ответах `/static-qr` может быть `null`. Поле приходит строкой, только когда платёжные ссылки включены; иначе в нём `null`. Печатному листу оно не нужно: в QR листа зашит `print_url`, и он работает независимо от этой настройки. Что делать: обрабатывайте `null` — не показывайте и не отправляйте ссылку, а покупателю давайте лист (`print_url`). Новых кодов ошибок, роутов и вебхук-событий нет.
- **2026-09-26** [CHANGED] Удаление позиции каталога, создание которой Kaspi не подтвердил, завершается одним из трёх исходов. Если подтверждение создания позиции от Kaspi не пришло, ApiPay сам проверяет, есть ли позиция в каталоге Kaspi. Есть — позиция снимается обычным удалением. Нет — удаление завершается без обращения к Kaspi, и приходит `catalog.item_processed`. Если исход установить не удалось, позиция получает `failed` с `error_code: catalog_delete_abandoned`; у организации с несколькими торговыми точками — `catalog_multi_tradepoint`, как при обычном удалении. У `catalog_delete_abandoned` поле `error_message` в `GET /catalog/errors`, `GET /catalog` и вебхуке теперь предлагает обратиться в поддержку. Повторное удаление такой позиции запускает проверку заново. Что делать: ничего; если вы показываете `error_message` пользователю, текст по этому коду стал точнее. Новых `error_code`, роутов и вебхук-событий нет.
- **2026-09-25** [NEW] Цены тарифов показывают скидку партнёра отдельно от суммы к оплате. Если организацию привлёк партнёр ApiPay и для неё действует скидка партнёра, суммы `plans[].price` и `tiers[].base_price` в `GET /tariff/plans` и `next_payment.amount` в `GET /tariff` указаны уже со скидкой. Рядом добавлена цена без скидки партнёра (скидка за длинный период в ней уже учтена): `plans[].gross_price`, `tiers[].gross_base_price`, `next_payment.gross_amount`. Размер скидки — в поле `partner_discount_percent` (целые проценты; 0 — скидки нет). `plans[].discount_percent` по-прежнему означает только скидку за длинный период. Прежние поля не переименованы. Что делать: сверяете цену с прайсом — берите `gross_price`, `gross_base_price` и `gross_amount`; показываете сумму к оплате — `price`, `base_price` и `amount`, как и раньше. Новых `error_code`, роутов и вебхук-событий нет.
- **2026-09-19** [CHANGED] Возврат по ссылке из счёта: POST /qr-refunds/links принимает return_items, reason и source_refund_id. amount, return_items и source_refund_id взаимоисключающие и требуют invoice_id. После подтверждённого отказа Kaspi план можно восстановить из исходного возврата. Сервер автоматически проводит согласованный возврат после подтверждения покупателя; при изменении суммы или состава деньги не списываются. Активный или неопределённый возврат блокирует повторный запуск. customer_url выдаётся один раз; настройка кабинета не меняет поведение API.
- **2026-09-19** [CHANGED] POST /subscriptions/{id}/resume возобновляет paused и expired, сохраняя billing_period, опору расписания и час списания. Для expired обнуляются неудачные попытки; пропущенные периоды не доначисляются. После subscription.expired может прийти subscription.resumed. Исчерпанные total_cycles дают HTTP 400 с error: Subscription has already delivered all paid cycles; другой недопустимый статус — HTTP 400 с error: Only paused or expired subscriptions can be resumed.
- **2026-09-17** [CHANGED] Статический QR (`POST /static-qr`) выпускает счёт только после нажатия покупателем «Оплатить», а не при открытии страницы. Открытие страницы оплаты — сканом камеры, повторным заходом, перезагрузкой или предпросмотром ссылки в мессенджере — счёт больше не создаёт: страница сначала показывает сделку (продавец, сумма, описание) и кнопку «Оплатить в Kaspi», а счёт в вашей организации появляется по нажатию — так же, как у одноразовой ссылки на оплату. Если покупатель уже отсканировал QR, но оплату не завершил, страница ждёт её и нового QR не выпускает; новый QR появляется, только если покупатель явно попросит его (страница предупреждает, что открытый в Kaspi QR нужно закрыть). В этом случае оплатить могут любой из двух QR, и после оплаты первого страница второго пишет «Оплачено по первому QR-коду». ⚠️ `scan_count` в `GET /static-qr/{id}` теперь считает открытия страницы (включая повторные и предпросмотры), а не выпущенные счета. ⚠️ Событие `invoice.qr_scanned` приходит и тогда, когда Kaspi сообщает, что покупатель уже подтверждает оплату, и его порядок относительно `invoice.status_changed` не гарантирован; поле `invoice.status` в нём — текущий статус счёта на момент отправки (может быть уже `paid` при `qr_substate: scanned`), а не всегда `pending`. ⭐ Что делать: не ждите счёт сразу после скана — он появляется, когда покупатель нажал «Оплатить». Реагируйте на `paid` по каждому `invoice.id` отдельно: у одного статического QR может быть два счёта, если покупатель попросил новый QR. Не откатывайте оплаченный заказ в «Ожидается» по позднему `invoice.qr_scanned` и не считайте `status` в нём признаком события — признак это `event`/`qr_substate`. Новых `error_code`, полей и роутов нет.
- **2026-09-17** [CHANGED] Ссылка «Возврат ApiPay» переживает пропущенное окно скана: покупатель, не успевший отсканировать код, может показать новый той же ссылкой. Если Kaspi подтвердил, что код не сканировали, ссылка возвращается в `awaiting_customer`: статус сессии в `GET /qr-refunds/{id}` идёт `awaiting_scan → awaiting_customer`, `error_code` = `qr_return_scan_timeout`, поля окна (`expires_at`, `scan_started_at`, `provider_requested_at`) снова `null`, вебхука нет. ⚠️ Окон у одной ссылки — до трёх: каждое открывается только нажатием покупателя и только в пределах `link_expires_at`, и каждое окно — отдельный возвратный QR у Kaspi. Если отсутствие скана подтвердить не удалось или Kaspi больше не знает операцию (`qr_return_not_found`), окно закрывается без повтора. ⚠️ `qr_refund.expired` приходит один раз — когда ссылка исчерпана: окно закрыто без повтора, пропущено последнее окно, истёк срок ссылки или она отозвана. ⚠️ `DELETE /qr-refunds/links/{id}` во время окна скана теперь отвечает `200` (раньше `409 qr_refund_link_not_revocable`), статус в снимке не меняется: текущее окно доживает своё, новых окон не будет. Пока Kaspi недоступен, нажатие покупателя окна не открывает и ссылку не тратит. Немедленный старт `POST /qr-refunds` не меняется. ⭐ Что делать: не считайте `awaiting_customer` после `awaiting_scan` ошибкой или концом сессии; концом ссылки считайте терминальный статус (`expired`, `failed`, `completed`, `execution_uncertain`).
- **2026-09-16** [CHANGED] Счёт по номеру телефона без `description` уходит покупателю с русским текстом вместо прежнего английского «Payment». Если вы не передаёте `description` в `POST /invoices` или в позиции `POST /invoices/bulk`, описание подставляем мы: при `cart_items` — названия позиций через запятую, без корзины — «Оплата счёта». Названия добираются целыми позициями, пока помещаются в видимые покупателю 60 символов: посередине названия текст не обрывается, а не поместившаяся позиция и все следующие за ней в описание не попадают. Переданное вами описание не трогается вовсе. ⚠️ `description` у таких счетов больше не `null`: он приходит непустым в `GET /invoices`, `GET /invoices/{id}` и в вебхуках `invoice.status_changed` и `invoice.qr_scanned`. У счетов, созданных до этого изменения, и у счетов, импортированных из истории Kaspi, `null` остаётся — поле по-прежнему nullable. ⚠️ То же правило действует на счёт по номеру телефона, выставленный со страницы статического QR без описания. ⛔ QR-счета (`POST /invoices/qr`) не затронуты: там описание и раньше становилось названием позиции в чеке, а пустое давало «Оплата». ⭐ Что делать: ничего, если вы всегда присылаете `description`. Если ваша интеграция считала `description === null` признаком «описания нет» — у новых счетов этот признак больше не сработает. Новых `error_code`, полей, роутов и вебхук-событий нет.
- **2026-09-15** [NEW] Режим подписки «выставлять счёт, пока не оплатят» — поле `bill_until_paid`. Необязательное boolean в `POST /subscriptions` и `PUT /subscriptions/{id}`; приходит в ответе подписки, у подписок, где режим не включали, — `false`. Мастер кабинета включает режим у подписок на другой период (не ежемесячных) — у таких подписок в ответе придёт `true`. Не передано или `false` — прежнее поведение: счёт перевыставляется, пока выставлено меньше `max_retry_attempts` счетов, затем льготный период и `expired`. `true` — подписка из-за неоплаты не истекает: истёкший счёт перевыставляется сразу, пока не наступил момент следующего планового списания, дальше идёт новый период с новым счётом. `retry_interval_hours` и `grace_period_days` в этом режиме не действуют, `subscription.grace_period_started` и истечение по неоплате не приходят. Счёт, отменённый не плательщиком (например, не хватило денег), и счёт в статусе `error` закрывают текущий период без повтора — `subscription.payment_failed` приходит как обычно. Исключение — ошибка на стороне сервиса: она, как и прежде, период сохраняет, и выставление повторится само. Если номер не зарегистрирован в Kaspi (`error_code = client_not_found`), подписка в этом режиме отменяется: приходит `subscription.cancelled` с `reason: payer_error`, `invoice_id` и `error_code`, а `subscription.payment_failed` по этому счёту не приходит. Явный отказ плательщика отменяет подписку в любом режиме, как и раньше. ⚠️ `bill_until_paid: true` вместе с `max_retry_attempts` → `422` по полю `max_retry_attempts`. На `PUT` отказ приходит, если итоговый режим — `true` (из тела или уже сохранённый), даже когда число равно текущему; `max_retry_attempts: null` означает «не менять». Смена режима в любую сторону обнуляет `failed_attempts`; включение у подписки в льготном периоде возвращает её к выставлению счетов. У автоплатежа по календарю поле `bill_until_paid` → `422`. ⚠️ В этом режиме неоплаченная подписка выставляет новый счёт каждый раз, как истёк предыдущий, — до следующего планового списания; `retry_interval_hours` их не замедляет. Эти счета создаются через API и расходуют дневной лимит тарифа наравне с остальными. Если неплательщиков много, закладывайте запас по лимиту или оставляйте таким подписчикам режим выключенным. ⭐ Если вы не включаете режим и не обслуживаете немесячные подписки, созданные мастером кабинета, ничего делать не надо: режим включён только у них. Если интеграция отправляет в `PUT` полный объект подписки из ответа — не передавайте `max_retry_attempts` у подписки с `bill_until_paid: true`. Добавьте в обработчик `subscription.cancelled` значение `reason: payer_error`. Новых `error_code`, роутов и вебхук-событий нет.
- **2026-09-15** [CHANGED] Если Kaspi отклоняет покупку по QR, счёт сразу переходит в `cancelled`. Kaspi может отклонить оплату на своей стороне и после того, как покупатель отсканировал QR. Раньше такой счёт оставался в `pending` и закрывался статусом `expired`; теперь приходит `invoice.status_changed` со статусом `cancelled`. ⚠️ `cancelled` у QR-счёта теперь означает одно из двух: покупатель отменил оплату сам либо Kaspi отклонил покупку. По вебхуку их не различить — если ваш интерфейс на `cancelled` пишет «клиент отказался», смягчите формулировку до «оплата не прошла». Отклонение покупки на стороне Kaspi не считается отказом плательщика: подписка из-за него не отменяется, `subscription.cancelled` с `reason: payer_refused` не приходит. По подписочным счетам `subscription.payment_failed` приходит как прежде, с `reason: "Invoice cancelled"` вместо `"Invoice expired"`. ⭐ Если вы обрабатываете `cancelled` и `expired` одинаково, ничего делать не надо. Если по-разному — учтите, что часть счетов, приходивших как `expired`, теперь придёт как `cancelled`. Оплатить такой счёт повторно нельзя — предложите покупателю новый QR или другой способ оплаты. Новых `error_code`, полей, роутов и вебхук-событий нет.
- **2026-09-14** [CHANGED] Фискальный чек выбивается и по позиции без НТИН и штрихкода — например, по услуге. Раньше `POST /receipts` отвечал на такую позицию `receipt.failed` с `error_code=item_not_fiscal` — и в бою, и в песочнице. Теперь позиция каталога без НТИН и без штрихкода уходит в чек без маркировки Нацкаталога, а позиция с НТИН — как прежде, с маркировкой. `item_not_fiscal` остаётся для позиции, которой нет в каталоге организации, которая в бою ещё не синхронизирована с Kaspi (`in_kaspi_catalog=false`; в песочнице это поле всегда `false`, и чек выбивается) или у которой есть штрихкод, но нет НТИН (`ntin_missing=true` — дорезолвите НТИН через `POST /catalog/scan`). Новый `error_code` `receipt_vat_not_supported` (`receipt.failed`): позиция со ставкой НДС — такие чеки не выбиваются. Произвольную сумму пробивайте универсальной позицией каталога (например, «Услуга») с `price` в строке `cart_items`. ⚠️ `shift_number` в `GET /receipts/{id}` и в вебхуке `receipt.issued` у новых чеков приходит `null`. ⭐ Если позиций без НТИН нет, ничего делать не надо — чеки с НТИН работают как раньше. Обработайте `receipt_vat_not_supported` в `receipt.failed` и не полагайтесь на `shift_number` как на обязательное поле. Новых полей, роутов и вебхук-событий нет.
- **2026-09-14** [NEW] Макросы в описании подписки: `{today}`, `{month}`, `{year}`. В `description` при `POST /subscriptions` и `PUT /subscriptions/{id}` можно писать макросы: подписка хранит шаблон, а значения подставляются в каждый счёт списания при его выставлении. `{today}` — дата выставления счёта в формате `дд.мм.гггг` по времени Алматы; повторный счёт после неоплаты получает свою дату. Это не дата оплаты. `{month}` (месяц строчными: «Оплата за {month}» → «Оплата за сентябрь») и `{year}` — месяц услуги и его год. Они есть только у автоплатежей по календарю, заведённых в кабинете; у остальных подписок они отвечают `422`. `{month}` — месяц услуги, а не месяц даты списания: счёт 29 сентября за октябрь покажет «октябрь». Неизвестный макрос или другой регистр (`{Today}`) → `422` по полю `description`; кириллица в фигурных скобках макросом не считается. Длина описания проверяется по тексту после подстановки, по прежнему правилу. `description` в ответе подписки — шаблон; готовый текст — в `description` счёта и вебхука `invoice.status_changed`. ⚠️ Если в уже сохранённом описании буквально стоит `{today}`, в новые счета туда подставится дата. `{month}` и `{year}` подставятся только у автоплатежа по календарю, у остальных подписок останутся текстом. Сохранённые описания не перепроверяются, пока их не меняют. ⭐ Если макросы не нужны, ничего делать не надо: описания без них работают как раньше. Новых `error_code`, полей, роутов и вебхук-событий нет.
- **2026-09-10** [CHANGED] Возврат по одноразовой ссылке (`POST /qr-refunds/links`) доступен всем мерчантам с действующим тарифом. Условие доступа — активный тариф, как у остальных платных операций: без него запрос отвечает `403 tariff_inactive`. Новых полей, роутов, `error_code` и вебхук-событий нет.
- **2026-09-06** [CHANGED] На время технических работ ручки отвечают «повторите позже» — каждая своим уже известным кодом и с заголовком `Retry-After`. Новых кодов, полей, роутов и вебхук-событий нет: меняется только повод, по которому эти коды приходят. Так теперь приходят `503 receipt_unavailable` на `POST /receipts` (чек НЕ создан и `client_operation_id` НЕ занят — повторяйте ТОТ ЖЕ запрос; если работы начались уже после приёма запроса, тот же `receipt_unavailable` приходит в `receipt.error_code` вебхука `receipt.failed`) и на `GET /invoices/{id}/receipt`; `503 receipt_preview_unavailable` на `POST /receipts/preview`; `503 Kaspi session unavailable` на `POST /clients/check`; `503 cashbox_unavailable` и `503 cashbox_toggle_unavailable` на чтениях и настройках кассы (у PDF-отчёта смены — свой `503 cashbox_report_unavailable`); `503 qr_refund_temporarily_unavailable` на выпуске ссылки и исполнении QR-возврата; `503 entrance_auth_disabled` на подключении и переавторизации кассира; `503 invoices_disabled` на `POST /invoices/qr`. Закрытие смены `POST /cashbox/shifts/close` отвечает `503 cashbox_unavailable`: операция НЕ создана и ключ НЕ занят, повторяйте ТЕМ ЖЕ `client_operation_id`; в перечне кодов вебхука `cashbox.shift_close_failed` `cashbox_unavailable` теперь тоже есть — там смена не закрыта, прежний ключ не освобождается, повторяйте с НОВЫМ. ⚠️ Телефонные счета не отказывают: `POST /invoices` и `POST /invoices/bulk` принимают запрос как обычно и отвечают `201` со статусом `processing` — счёт уходит в Kaspi сам, повторять его НЕ нужно (повтор создаст второй счёт). Статусы и вебхуки по такому счёту приходят штатно, но перехода из `processing` можно ждать заметно дольше обычного — не помечайте счёт неуспешным по собственному таймауту. ⛔ Исключение — `POST /invoices/qr`: он отвечает `503 invoices_disabled`, QR не создаётся, повторите позже. ⭐ Ретраите с бэкоффом в минутах и показывайте «сервис временно недоступен», а не техническую ошибку; `Retry-After` — подсказка, а не обещание срока.
- **2026-09-06** [REMOVED] `403 fiscal_receipts_disabled` больше не приходит: `POST /receipts` и `POST /receipts/preview` этим кодом не отвечают, в `receipt.error_code` вебхука `receipt.failed` его тоже нет. Значение снято из перечня `error_code`, но остаётся историческим: в старых логах и записях доставки вебхуков трактуйте его как прежде. Что делать: ничего — ветку обработки можно оставить, она перестанет срабатывать. Если она показывала мерчанту «выбивание чеков недоступно», уберите этот текст из интерфейса. Остальные отказы чека не менялись. Новых кодов, полей, роутов и вебхук-событий нет.
- **2026-09-06** [REMOVED] `503 av_unavailable` больше не приходит при загрузке скриншотов: дополнительная проверка файла снята. Ни `POST /business-profile/screenshots`, ни партнёрская загрузка скриншотов этим кодом не отвечают; значение снято из перечня `error_code`, но остаётся ИСТОРИЧЕСКИМ — в старых записях трактуйте его как прежде. ⚠️ Требования к самому файлу не изменились ни в одном пункте: формат, габариты, соотношение сторон и потолок размера прежние. ⭐ Ветку обработки `av_unavailable` можно оставить — она просто перестанет срабатывать; если она показывала мерчанту «проверка файла временно недоступна», уберите этот текст из интерфейса. ⚠️ Остальные отказы загрузки не менялись: `422 image_rejected`, `429 screenshot_quota_exceeded`, `500 image_processing_unavailable`, `500 upload_unavailable`, `413` на слишком большой файл.
- **2026-09-06** [CHANGED] Подключение кассира требует одобренной анкеты «Расскажите о бизнесе»: `POST /connections/{connection}/auth/init` и `.../auth/send-phone` отвечают `403 kyc_required`, пока анкета организации не одобрена, — SMS кассиру при этом не отправляется вовсе. Тем же кодом терминально отвечает `.../auth/verify-otp`, если одобрение перестало действовать между шагами: Kaspi код уже принял, но подключение не создаётся — после одобрения нужен новый `init`. ⛔ Код неретраибельный: повторять до одобрения бесполезно, решение принимает человек. ⭐ Стройте онбординг в порядке «регистрация → анкета → подключение кассира → боевые счета», а ветку `kyc_required` показывайте как «дождитесь одобрения анкеты», а не как ошибку. ⚠️ Переподключение уже привязанного кассира (тот же номер) не затронуто; песочница тоже — тестовая организация получает `409 test_organization` как и раньше. Новых кодов, полей, роутов и вебхук-событий нет.
- **2026-09-06** [CHANGED] `403 static_qr_disabled` больше не приходит на генерацию печатного листа: `POST /static-qr` этим кодом не отвечает — лист выпускается, пока активен тариф. ⚠️ Значение остаётся живым при создании одноразовой платёжной ссылки (`static=true`): там `403 static_qr_disabled` приходит как прежде и означает, что режим ссылок для организации недоступен, — ветку обработки сохраните. ⭐ Ветку обработки можно оставить — на генерации листа она просто перестанет срабатывать; если она показывала мерчанту «печатный QR временно недоступен», уберите этот текст из интерфейса. ⚠️ Остальные отказы генерации не менялись: `403 tariff_inactive`, `403 kyc_rejected` и проверки «этот лист сможет материализоваться» в `422`. ⚠️ Страница оплаты для покупателя при остановке приёма платежей показывает временную страницу с предложением повторить, а не «не найдено». Новых кодов, полей, роутов и вебхук-событий нет.
- **2026-09-04** [NEW] Новый код отказа возврата — `refund_insufficient_funds`: на счёте Kaspi Pay мерчанта не хватает денег на возврат. Раньше этот отказ приходил как `502 kaspi_error` с нейтральным «попробуйте позже», из-за чего возврат повторяли вхолостую: без пополнения счёта результат тот же. Теперь `POST /qr-refunds/{id}/execute` отвечает `422 refund_insufficient_funds`. Отказ происходит ДО отправки денег: ничего не списано, сессия остаётся `customer_identified`, и после пополнения счёта возврат повторяется на ней же, пока не истёк срок идентификации покупателя (после — новая ссылка). Тот же код приходит асинхронно в `refund.error_code` вебхука `invoice.refunded` (`status=failed`) для `POST /invoices/{id}/refund`. Песочница принимает новое значение в `simulate.error_code`. ⚠️ Ветку `kaspi_error` не убирайте — она по-прежнему приходит на прочие ошибки Kaspi. Новых полей, роутов и событий нет.
- **2026-09-02** [NEW] Поле `is_imported` у счёта описано в контракте. Приходит в `GET /api/v1/invoices` (в каждом элементе `data`), в `GET /api/v1/invoices/{id}` и в ответах на создание счёта. `true` означает, что продажа подтянута из истории Kaspi, то есть проведена в приложении Kaspi Pay мимо ApiPay. Это та же ось, что у фильтра `origin`: `origin=kaspi` отбирает ровно счета с `is_imported: true`. ⚠️ Форма ответа не менялась — поле приходило и раньше, просто не было описано; если вы его уже читаете, делать ничего не нужно. ⚠️ В ответе на создание счёта поле всегда `false`: счёт, выставленный через ApiPay, подтянутым из Kaspi быть не может. Новых полей, роутов и событий нет.
- **2026-09-02** [NEW] Возврат по QR теперь можно отправить покупателю ссылкой. POST /qr-refunds/links выпускает одноразовую ссылку «Возврат ApiPay» и отвечает 201; Kaspi при этом не вызывается и таймер не идёт. Покупатель открывает ссылку и нажимает кнопку — только тогда создаётся возвратный QR и начинается окно скана. Поле customer_url отдаётся РОВНО ОДИН РАЗ: получить адрес повторно нельзя ни через GET /qr-refunds/{id}, ни как-либо ещё, поэтому сохраните его сразу. Ссылка предъявительская — кто её открыл, тот может подтвердить возврат; не публикуйте её и не кладите в логи и аналитику. Отправляйте ссылку только тому покупателю, которому возвращаете деньги: сессия опознаёт того, кто её открыл и подтвердил в Kaspi, и покажет ЕГО покупки. Срок неактивированной ссылки — link_expires_at, 24 часа с выпуска, и он не продлевается ничем: ни открытием страницы, ни перезагрузкой, ни неудачной попыткой. Одна ссылка материализует не более одной операции у Kaspi. Отозвать неоткрытую ссылку — DELETE /qr-refunds/links/{id}: отвечает снимком сессии со статусом expired и error_code qr_refund_link_revoked, повторный отзыв идемпотентен и возвращает тот же снимок. Уже открытую ссылку отозвать нельзя — 409 qr_refund_link_not_revocable. Потеряли адрес — отзовите ссылку через DELETE /qr-refunds/links/{id} и выпустите новую; неотозванная ссылка действует до link_expires_at. Отзыв доступен и при истёкшем тарифе; выпуск новой ссылки, как и раньше, требует активного тарифа. Если достигнут максимум неиспользованных ссылок — до 20 одновременно, выпуск отдаёт 429 qr_refund_link_limit_reached. После того как покупатель откроет ссылку, начинается окно скана (не более 90 секунд), а после подтверждения на выбор покупки и возврат остаётся ограниченное время — дальше 409 qr_refund_expired. Слушайте qr_refund.identified или опрашивайте GET /qr-refunds/{id}. Дальнейшее состояние читается через GET /qr-refunds/{id}, денежное выполнение — существующим POST /qr-refunds/{id}/execute: сама ссылка денег не двигает.
- **2026-09-02** [CHANGED] У POST /qr-refunds/{id}/execute появился класс ответов 202 — исход не доказан. Это не ошибка запроса: он принят, единственная денежная попытка потрачена, а ответ провайдера не доказал ни успех, ни отказ. Деньги могли уйти. ⛔ Повторять execute на 202 НЕЛЬЗЯ ни при каких условиях: второй запрос — это второй возврат живых денег покупателю. Retry-After в таком ответе не приходит и ретрай на него строить не нужно. Кодов два: qr_refund_execution_uncertain — с полями снимка сессии в статусе execution_uncertain, параллельно уходит вебхук qr_refund.execution_uncertain; qr_refund_execution_result_unavailable — намеренно БЕЗ снимка, потому что достоверное состояние сессии в этот момент неизвестно. Повтор execute поверх недоказанного исхода отвечает 409 qr_refund_execution_uncertain, поверх уже идущего возврата — 409 qr_refund_execution_in_progress; денег ни один из них не двигает, но и повторять на них нечего. Оба исхода разбираются вручную — обратитесь в поддержку. Остальные отказы execute (403, 409 qr_refund_not_identified и qr_refund_expired, 422, 503) по-прежнему происходят ДО отправки денег: сессия остаётся customer_identified, и повторить с другой операцией или суммой можно. 502 kaspi_error тоже до отправки денег — Kaspi не ответил внятно, повтор допустим.
- **2026-09-02** [CHANGED] Окно скана возвратного QR — не более 90 секунд, и поле scan_wait_timeout_seconds сообщает действующее значение. Поле expires_at теперь тоже отражает действующий конец окна и отсчитывается от scan_started_at. Просроченные qr_token_url и qr_image_url не выдаются — ни в ответе на старт, ни в GET /qr-refunds/{id}: у сессии, выпущенной ссылкой покупателю, оба поля всегда null, потому что цель отдаётся один раз на публичной странице. Если вы зашили ожидание скана в константу или считали таймер сами — возьмите значение из scan_wait_timeout_seconds. Окно, закрывшееся до выдачи QR, даёт 409 qr_return_scan_timeout; окно, закрывшееся после подтверждения покупателя, записывает в сессию error_code qr_return_identity_timeout — в обоих случаях возврат не сделан, деньги не двигались, и нужна новая ссылка.
- **2026-09-02** [DEPRECATED] POST /qr-refunds — немедленный старт возврата — помечен deprecated в пользу ссылки POST /qr-refunds/links. Причина простая: покупателя обычно нет рядом с кассой, и отсканировать возвратный QR с чужого экрана ему нечем. Эндпоинт остаётся рабочим, удалять его не планируется, ломающих изменений в нём нет. Для новых интеграций используйте ссылку. Напоминание по немедленному старту: попытка одноразовая, повторять тот же запрос нельзя. 202 qr_refund_activation_in_progress означает, что запрос ушёл, а исход ещё не записан — читайте состояние через GET /qr-refunds/{id}. 502 qr_refund_activation_failed означает «начать не удалось, нужен новый старт», а не «попробуйте ещё раз тем же запросом»; тот же факт приходит вебхуком qr_refund.failed.
- **2026-09-01** [NEW] Кассира можно отключить запросом: POST /connections/{connection}/auth/logout. Ручка отключает кассира на стороне ApiPay: удаляет сохранённую сессию, а само подключение и его история остаются на месте; kaspi_user_id освобождается и в GET /connections приходит null. Наружу в Kaspi запрос не уходит, поэтому ни OTP, ни лимиты Kaspi здесь не тратятся. Это не удаление: DELETE /connections/{connection} по-прежнему отдельная ручка, и её ограничения на последнего и основного кассира к отключению не применяются — отключить можно любого. Повторный вызов на уже отключённом подключении отвечает тем же 200 и состояние не меняет. Ключу нужно право управления кассирами, иначе придёт 403 cashier_management_disabled. ⚠️ Пока кассир отключён, эта точка не принимает платежи: создание счёта отдаёт отказ kaspi_session_not_configured. ⚠️ Денежное последствие: возвраты по счетам, оплаченным через этого кассира, через API не проходят — запрос принимается, но возврат завершается статусом failed (вебхук invoice.refunded со status: failed). Такие возвраты проводят вручную в приложении Kaspi Pay. Проведите нужные возвраты до отключения. Чтобы подключить тот же номер обратно, пройдите мастер авторизации целиком — init, send-phone, verify-otp; кассир снова подтверждает SMS-код от Kaspi.
- **2026-09-01** [CHANGED] Ручное имя кассира сохраняется при переавторизации. Если вы задали label в POST /connections или PUT /connections/{connection}, после повторного входа кассира в поле придёт то же самое имя. Форма ответа не изменилась: label остаётся одной строкой и по-прежнему несёт имя, которое надо показать, — ручное, если оно задано, иначе имя по умолчанию. Если вы переустанавливали имя после каждой переавторизации, этого больше не требуется. ⚠️ Заодно строже проверяется само поле: label из одних пробелов при создании и переименовании кассира теперь отдаёт 422 — раньше такая строка принималась. Если label в POST не передавать вовсе, остаётся имя по умолчанию, как и прежде.
- **2026-08-31** [CHANGED] Поиск по счетам ищет `%` и `_` буквально. В параметре `search` (`GET /api/v1/invoices` и экспорт счетов) эти два символа теперь ищутся как обычный текст: запрос «скидка 50%» находит счета со строкой «скидка 50%». ⭐ Делать ничего не нужно, если вы не рассчитывали на иное поведение этих символов.
- **2026-08-31** [CHANGED] Фильтры `GET /api/v1/subscriptions` перестали терять все значения, кроме первого. Если фильтр передавался массивом (`status[]=active&status[]=paused`), учитывалось только ПЕРВОЕ значение, а ответ приходил `200` — выдача выглядела правдоподобной, но была уже урезанной. Теперь массив означает «любое из перечисленного»; форма с одним значением (`status=active`) работает как раньше. То же касается `external_subscriber_id` и `phone_number`. ⭐ Если ваш код рассчитывал на прежнюю урезанную выдачу — проверьте его.
- **2026-08-31** [CHANGED] Неизвестное значение `status` в `GET /api/v1/subscriptions` отвечает `422`. Раньше приходил `200` с пустым списком, и опечатка в фильтре была неотличима от честного «подписок нет». Список допустимых значений не изменился: `active`, `paused`, `cancelled`, `expired`. ⭐ Если фильтр собирается из пользовательского ввода — обработайте `422`.
- **2026-08-30** [CHANGED] Ключ организации, отправленной в архив, отвечает `403 organization_archived` вместо прежнего `401`. Раньше на такой запрос приходил `401` про непривязанный ключ — отказ читался как «ключ протух», и ключ перевыпускали вхолостую. Теперь это `403 organization_archived` на любой операции такого ключа: ключ активен, дело не в нём — доступ возвращает владелец аккаунта, перевыпуск ничего не меняет. ⚠️ Различайте два отказа: `401` по-прежнему значит «ключ неверен, истёк или деактивирован» и лечится перевыпуском, а `403 organization_archived` — «ключ цел, недоступна организация». Что именно случилось с организацией, ответ не раскрывает.
- **2026-08-30** [NEW] Подписка теперь сама выбирает дату, время и число оплат. В POST /subscriptions (и в PUT /subscriptions/{id}, кроме first_billing_at) появились четыре аддитивных поля. billing_day_from_end — опора от конца месяца: 0 — последний день, 1 — предпоследний; доступна для monthly, quarterly и yearly, взаимоисключающа с billing_day. billing_time — время списания по Алматы в формате ЧЧ:ММ, окно 06:00–22:00. first_billing_at — дата первого списания (ГГГГ-ММ-ДД, календарь Алматы); без неё первое списание по-прежнему считается как started_at плюс период, а вместе с bill_immediately она отдаёт 422. total_cycles — сколько ОПЛАЧЕННЫХ списаний сделать за всю жизнь подписки; неоплаченная попытка цикл не расходует, по достижении лимита подписка переходит в expired, а вебхук subscription.expired несёт в корне payload reason: total_cycles_reached, cycles_paid и total_cycles. В ответе добавились billing_day_from_end, billing_time, total_cycles и cycles_paid. Изменение расширяющее: запросы, работавшие раньше, работают и сейчас — billing_day остаётся 1–28.
- **2026-08-30** [CHANGED] Время списания по умолчанию перенесено на 13:00 по Алматы. Раньше временем по умолчанию было 05:00. Новое время применяется ко всем подпискам при первом же пересчёте после очередного списания; чтобы выбрать своё, передайте billing_time в окне с 06:00 до 22:00. Значение next_billing_at в ответе теперь содержит ненулевое время суток — если вы сравнивали его как чистую дату, учтите это.
- **2026-08-30** [CHANGED] Интервал повтора при неоплате соблюдается — и не применяется к истёкшим счетам. Раньше повтор уходил в течение минуты независимо от значения retry_interval_hours. Теперь интервал выдерживается для отказов по существу — например, когда у номера нет Kaspi. Истёкший счёт перевыставляется сразу, интервал не ждёт: срок жизни счёта плательщик уже израсходовал, и второе ожидание сверху было бы двойным. Если плательщик отклонил счёт явно, подписка отменяется сразу, а вебхук subscription.cancelled несёт в корне payload reason: payer_refused и invoice_id; нехватка средств у плательщика отказом не считается и по-прежнему ведёт к обычному повтору.
- **2026-08-30** [CHANGED] Пропущенные периоды больше не выставляются пачкой. Если по подписке долго не удавалось списать — например, у организации не было подключённого кассира, — при возобновлении выставляется один счёт за текущий период, а расписание переходит на ближайшую будущую дату. Раньше клиент получал по счёту за каждый пропущенный период подряд.
- **2026-08-30** [CHANGED] Поля next_billing_in_days и next_billing_label в подписке стали согласованными. next_billing_in_days теперь знаковое число дней по календарю Алматы: отрицательное означает просрочку. Раньше поле отдавало модуль, и вчерашнее списание выглядело как завтрашнее. next_billing_label больше не показывает «просрочено» для списания, которое ещё сегодня предстоит. Что делать: если вы сравнивали next_billing_in_days с нулём, учтите знак.
- **2026-08-30** [CHANGED] У недельных подписок день списания ограничен днями недели. У weekly и biweekly поле billing_day означает день недели: 1 — понедельник, 7 — воскресенье. Значения больше 7 раньше принимались и молча игнорировались — теперь отдают 422. Что делать: если вы передавали туда число месяца, замените его днём недели или уберите поле.
- **2026-08-30** [CHANGED] Описание подписки ограничено так же, как описание счёта. Поле description в POST и PUT /subscriptions теперь проверяется по тому же правилу, что и у обычных счетов: Kaspi показывает покупателю только первые 60 символов и остальное молча отбрасывает. Слишком длинное описание отдаёт 422. Проверяется только изменённое описание: правка других полей у подписки со старым длинным описанием проходит как раньше. Подписка без описания теперь выставляет счёт с русским текстом вместо прежнего английского: «Оплата подписки №{id}», а в песочнице — «Оплата подписки №{id} (песочница)».
- **2026-08-30** [CHANGED] Списки подписок и платежей ограничены 100 записями на страницу. Значение per_page больше 100 в GET /subscriptions и GET /subscriptions/{id}/invoices теперь приводится к 100 — как и во всех остальных списках API.
- **2026-08-29** [CHANGED] Тумблеры кассы (`PUT /cashbox/settings/auto-close`, `PUT /cashbox/settings/auto-withdrawal`) переключает только ключ, выпущенный ВЛАДЕЛЬЦЕМ организации. Ключ, привязанный к сотруднику, получает `403 cashbox_settings_owner_key_required`; повтор запроса не поможет — перевыпустите ключ от имени владельца. ⚠️ Чтение не затронуто: `GET /cashbox/settings` по-прежнему доступно любому ключу организации. ⚠️ Остальные кассовые операции — смены, сводка, сверка, отчёт, закрытие смены — не изменились. Правило то же, что на `POST /catalog/bulk-delete`.
- **2026-08-29** [CHANGED] `POST /catalog/bulk-delete` снимает только позиции вашей интеграции; остальные возвращаются в новом поле `not_yours`. Каталог у мерчанта общий, и раньше одним запросом можно было снять в нём всё — включая позиции, заведённые самим мерчантом и соседней интеграцией. Теперь удаляются только заведённые вами, а идентификаторы чужих и общих позиций приходят списком в `not_yours` и остаются на месте. Поле есть во всех телах ответа, включая `dry_run`, где `would_delete` тоже считает только ваши позиции. ⚠️ Право на массовое удаление не отбирается — сужается область. ⚠️ Ключа самого мерчанта и запросов из кабинета изменение не касается: там удаляется всё, как и раньше. ⚠️ Полную синхронизацию каталога это не ломает: позиции, залитые вами, остаются вашими и снимаются как прежде.
- **2026-08-29** [NEW] У позиции каталога появилось поле `source` (`own` / `shared` / `other`) и параметр `GET /catalog?source=own`. У одного мерчанта может работать несколько интеграций, и каталог у них общий: `GET /catalog` как отдавал, так и отдаёт весь каталог мерчанта. Теперь видно, чьё что: `own` — завели вы, `shared` — завёл сам мерчант в кабинете либо позиция приехала из каталога Kaspi, `other` — завела другая интеграция (её имя не раскрывается). Нужны только свои позиции — попросите их явно: `?source=own`. ⚠️ Список по умолчанию не фильтруется намеренно: пустая выдача читалась бы как «залить всё», и позиции мерчанта без штрихкода и `external_ref` уехали бы в Kaspi дублями. ⚠️ Поле аддитивное, остальной ответ не изменился; оно же есть в `GET /catalog/queue` и `GET /catalog/errors`. ⚠️ Для ключей самого мерчанта и запросов из кабинета `source` всегда `own`. Изменить или снять позицию с `source: other` нельзя — приходит `409 catalog_item_foreign_channel`.
- **2026-08-27** [CHANGED] Позиция, создание которой брошено, больше не продаётся. Строка со status: failed и operation: create раньше отдавала sellable: true и принималась в корзину счёта, QR, статического QR и подписки — при том, что её создание в каталоге Kaspi не подтверждено и подтверждено уже не будет. Теперь у неё sellable: false, и корзина отбивает такую позицию 422 (в POST /invoices/bulk — позицией failed в invoices[]). Возобновляет работу по строке PATCH /catalog/{id}, он же «Повторить» в кабинете; повторный POST /catalog возобновляет брошенное создание не всегда, условия — в записи про поле outcome за это же число. Автосписание по подписке с такой позицией теперь считается неудачным прогоном, а не отсрочкой: раньше списание молча переносилось бы на следующий раз, и так каждый раз. Неудачные прогоны увеличивают failed_attempts, поэтому позицию нужно починить до исчерпания max_retry_attempts — иначе подписка уйдёт в grace period. Позиция, создание которой ещё в работе (status: pending), продаётся — правило то же; исключением было переиздание снятой позиции, см. запись про переиздание за это же число.
- **2026-08-27** [CHANGED] Ответ POST /catalog говорит, что сделано с позицией: поле outcome. Признак matched_existing: true означает только «мы нашли вашу строку» и не отличает выполненную работу от её отсутствия — брошенное создание, по которому ничего не произошло, выглядело в ответе так же, как успешный матч. Новое аддитивное поле outcome в каждой позиции data[] называет исход прямо: created — заведена новая строка; matched — сматчилась существующая, правка применена или поставлена в очередь; reissued — переиздана снятая позиция; unchanged — строка уже совпадает с каталогом Kaspi, работа не нужна; revived — брошенное создание открыто заново; not_started — брошенное создание найдено, но работа не открыта. В остальных ответах каталога поле null, matched_existing и name_differs работают как раньше. Ветвитесь по outcome, а не по matched_existing. Заодно изменилось поведение повторной заливки брошенного создания. Она открывает работу заново (outcome: revived) в двух случаях: прошлый отказ был отказом доставки, а не разбором данных (catalog_delivery_incomplete, network_unavailable, session_transient, kaspi_throttled), либо вы прислали позицию с изменённым наименованием, ценой, штрихкодом, НТИН или GTIN. Отказы catalog_create_unresolved и catalog_create_blocked дословный повтор не смывает: их дожимает PATCH /catalog/{id}. Если не выполнено ни одно из двух условий, дословный повтор работу не открывает — придёт outcome: not_started с прежними status, error_code и failed_at, и Kaspi позван не будет. Безусловный способ дожать позицию — PATCH /catalog/{id}. Повторная заливка брошенной позиции без external_ref больше не плодит вторую строку. Если вы шлёте Idempotency-Key, повтор обязан идти с новым ключом: точный повтор тела со старым отвечает 200 и idempotent_replay: true и до матчинга не доходит вовсе.
- **2026-08-27** [NEW] Новый error_code: catalog_delivery_incomplete. Появляется на строке каталога (GET /catalog/errors, поле error_code, и событие catalog.item_processed) и означает недоставку, а не отказ по существу: позицию не удалось довезти до Kaspi, и Kaspi её ни разу не видел. Раньше этот случай приходил под кодом catalog_item_invalid, который означает «данные позиции отвергнуты», — на нём он больше не приходит. Переотправьте позицию тем же POST /catalog (тот же external_ref — та же строка), данные менять не нужно. Если вы шлёте Idempotency-Key, повтор должен идти с новым ключом: иначе ответ вернётся из кеша идемпотентности и работа по строке не откроется. Если ваш код ветвится по error_code, добавьте новый код в ветку повторной отправки. Сам текст error_message не изменился, разбирайте отказ по коду.
- **2026-08-27** [NEW] Два новых error_code на строке каталога: catalog_create_unresolved и catalog_create_blocked. Оба приходят в GET /catalog/errors (поле error_code) и в событии catalog.item_processed. Раньше позиция, создание которой не удалось довести до конца, могла оставаться в status: pending неограниченно долго — и всё это время считалась продаваемой, то есть счёт уходил на товар, создание которого не подтверждено. Теперь такая позиция по истечении длительного ожидания переводится в status: failed с одним из двух кодов: catalog_create_unresolved — запрос на создание был отправлен, но подтверждения по позиции мы так и не получили; catalog_create_blocked — записывать каталог от имени вашей организации было невозможно слишком долго, например подключение кассира перестало быть активным. Убедитесь, что подключение кассира на месте, и дожмите позицию через PATCH /catalog/{id}. Дословный повтор POST /catalog такую позицию не возобновляет: возобновляет PATCH либо заливка с изменённым наименованием, ценой, штрихкодом, НТИН или GTIN. Позиция с этими кодами отдаёт sellable: false и отбивается корзиной счёта, QR, статического QR и подписки. У catalog_create_unresolved позиция могла всё-таки оказаться в каталоге Kaspi — прежде чем заводить её заново, проверьте каталог в приложении Kaspi Pay, иначе получите две одинаковые позиции.
- **2026-08-27** [CHANGED] Товар, который уже есть в каталоге Kaspi, чаще подхватывается существующей строкой, а не оседает ошибкой. Если при заливке выясняется, что такой товар в каталоге Kaspi уже заведён, сервис пытается связать заявку с вашей же существующей позицией вместо того, чтобы отдать error_code: catalog_item_duplicate. Раньше это работало только для позиций со штрихкодом или НТИН; теперь работает и для позиции, у которой из опознавательных признаков есть только наименование — при условии, что в вашем каталоге ровно один товар с таким наименованием. Двух и более тёзок сервис по-прежнему не разбирает и честно отдаёт catalog_item_duplicate: угадывание тут означало бы привязку заявки к чужому товару. Менять ничего не нужно, поведение расширяющее; external_ref остаётся самым надёжным ключом маппинга.
- **2026-08-27** [CHANGED] Переизданная позиция снова доезжает до Kaspi и продаётся. Если external_ref указывает на снятый товар, POST /catalog переиздаёт ту же строку (тот же id) — так было и раньше. Но до этого исправления такая строка не отправлялась в Kaspi вовсе: она бесконечно читалась как status: pending с operation: create и при этом отдавала sellable: false, то есть ни создавалась, ни продавалась. Теперь она обрабатывается как любая создаваемая позиция: уходит в Kaspi штатной очередью, показывается в GET /catalog/queue и принимается в корзину (sellable: true) ещё до подтверждения — как и любая другая позиция со status: pending. Словарь status не изменился, новых полей нет: если вы ждали active перед продажей, ничего менять не надо. Если ваш код опирался на sellable: false у переиздания — это было следствием дефекта, а не правилом.
- **2026-08-26** [CHANGED] BREAKING: до одобрения анкеты «Расскажите о бизнесе» боевые счета не выставляются вовсе. Раньше организации без одобренной анкеты был доступен один реальный счёт в сутки — теперь ноль: первый же POST /invoices (а также /invoices/qr и автосписания подписок) отвечает 429 kyc_daily_limit_reached. Форма ответа не изменилась и новых error_code нет — поменялись meta.limit (теперь 0) и текст message, поэтому ветвитесь по meta.limit, а не по зашитой константе. Организации со статусом approved и организации в грейсе (kyc_deadline в будущем) не затронуты; blocked по-прежнему отдаёт 403 kyc_rejected. Песочница не затронута и анкеты не требует: интеграцию можно полностью отладить там. Порядок подключения: регистрация → анкета → подключение кассира → боевые счета.
- **2026-08-26** [CHANGED] BREAKING: снятие корзины у подписки теперь требует amount. PUT /subscriptions/{id} с "cart_items": null снимал корзину и раньше, но оставлял amount, посчитанный по уже снятому составу — подписка продолжала списывать это число дальше. Теперь такой запрос без amount отбивается 422 по полю amount; передайте сумму тем же запросом. Если вы снимали корзину, полагаясь на прежнее поведение, добавьте amount в тело. null и отсутствие поля по-прежнему разные намерения: если cart_items не передан вовсе, корзина не трогается.
- **2026-08-26** [CHANGED] Организация с каталогом может завести подписку просто на сумму. POST /subscriptions больше не требует cart_items при включённом каталоге: передайте либо amount, либо cart_items (с корзиной сумму по-прежнему считает сервер, amount из тела игнорируется). Если не передано ни то, ни другое — 422 по полю amount, а не по cart_items, как было раньше. Это выравнивает подписки со счетами по номеру телефона: списание по подписке и так уходит обычным счётом на телефон, где корзина опциональна. Обратное направление не изменилось — cart_items у организации без каталога дают 422 с error_code: catalog_not_supported (теперь код есть и на создании, не только на PUT). Изменение расширяющее: запросы, работавшие раньше, работают и сейчас.
- **2026-08-26** [CHANGED] Отказ «у организации нет каталога» звучит одинаково на всех ручках. У POST /invoices/bulk в per-item failed текст был короче, чем на остальных поверхностях. Теперь везде одна формулировка. error_code (catalog_not_supported), HTTP-код и структура ответа не менялись; разбирайте отказ по коду, текст стабильным мы не обещаем.
- **2026-08-26** [CHANGED] Описание счёта ограничено 60 символами. Причина внешняя: Kaspi показывает покупателю только первые 60 символов описания, а всё, что длиннее, отбрасывает — счёт при этом создаётся, ошибки не возвращается, и узнать об усечении невозможно. Принимать больше значит обещать текст, которого покупатель не увидит. Что делать: уложите описание в 60 символов — вынесите в начало то, по чему плательщик узнает платёж (номер заказа, имя), а подробности уберите. Слишком длинное описание приходит как 422 с error_code: description_too_long и именем поля в errors. В POST /invoices/bulk отказ приходит построчно в invoices[]: ответ остаётся 201, соседние счета создаются, повторить нужно только эту позицию. К POST /invoices/qr это не относится — там description задаёт наименование позиции в чеке Kaspi, у него свой лимит 100 символов, и он не менялся. У подписки description живёт по тому же правилу — см. запись за 30.08.2026. Покупателю Kaspi и здесь показывает только первые 60 символов, поэтому узнаваемое (номер договора, название абонемента) держите в начале. ⚠️ Печатный лист POST /static-qr затронут тоже, и здесь важен порядок действий: описание задаётся один раз при создании и у выпущенного листа не меняется — ручки правки у листа нет (POST, GET, DELETE). На странице листа у покупателя всегда есть запасной путь «оплата по номеру телефона», и он уходит обычным телефонным счётом: у листа с описанием длиннее 60 символов этот путь не сработает. Оплата сканированием QR не затрагивается — там свой лимит 100 символов. Поэтому POST /static-qr отбивает слишком длинное описание сразу, тем же 422 description_too_long — лист с мёртвым телефонным рельсом просто не выпустится. Уже выпущенному листу замены текста нет: ручки правки описания у листа не существует — отключите его (DELETE /static-qr/{id}) и напечатайте новый, у нового листа свои token и short_code.
- **2026-08-26** [NEW] Новый error_code: field_too_long (HTTP 422). Приходит, когда значение поля превышает допустимую длину. Конверт обычный — {message, errors, error_code}, имя поля лежит ключом в errors, и укорачивать нужно именно его. Повторять запрос с тем же телом бесполезно: код неретраибельный. Это страховочный код: штатно слишком длинное значение отсекается обычной ошибкой валидации, форма которой не изменилась, поэтому существующую ветку обработки 422 править не нужно — достаточно не считать незнакомый error_code общим сбоем. В POST /invoices/bulk он приходит не на весь запрос, а строкой в invoices[] у конкретной позиции: ответ остаётся 201, соседние счета создаются.
- **2026-08-20** [CHANGED] Правка и одиночное удаление позиции каталога в боевом режиме стали полностью асинхронными. PATCH /catalog/{id} и DELETE /catalog/{id} больше не ходят в Kaspi внутри HTTP-запроса: они отвечают 202, а доставка идёт уже после ответа и при загруженной очереди занимает больше минуты. Ответ 202 подтверждением не является: исход проверяйте точечным GET /catalog, счётчиками GET /catalog/queue, журналом GET /catalog/errors или событием catalog.item_processed. Новая картинка и запрос на её удаление сохраняются до подтверждённой правки. У одиночного удаления неоднозначная торговая точка больше не даёт синхронный 409: запрос принимается с 202, а код catalog_multi_tradepoint появляется на самой позиции. У массового удаления синхронная проверка и 409 остаются.
- **2026-08-20** [NEW] GET /catalog/queue теперь показывает счётчик updating. Поля data и total по-прежнему описывают только создание новых позиций, deleting — снятие с продажи, а updating — сколько существующих позиций ещё ждут подтверждения правки в Kaspi. Это важно при переоценке: в ответе GET /catalog новая цена появляется сразу, поэтому сравнение отправленного значения с ответом не доказывает, что правка доехала до Kaspi. Дождитесь updating равного нулю и смотрите по каждой позиции поля operation и error_code. Поле аддитивное, прежние поля ответа не изменились.
- **2026-08-20** [REMOVED] Агрегаты операций каталога удалены. POST /catalog и POST /catalog/bulk-delete больше не возвращают блок batch и poll_url, ручка GET /catalog/batches/{id} и агрегатное событие вебхука удалены. Фильтр batch_id у GET /catalog и GET /catalog/errors не игнорируется молча, а отклоняется кодом 422 catalog_batch_filter_removed: молчаливый игнор вернул бы 200 со всем каталогом вместо позиций партии, то есть ответ, неотличимый от правды. Вместо агрегатов: остаток работы — GET /catalog/queue, конкретные строки — точечный GET /catalog с параметром external_refs, отказы — GET /catalog/errors с окном from и to по моменту отказа, push — событие catalog.item_processed по каждой строке. Если вы жили только на вебхуках, обратите внимание: событие catalog.batch_processed больше не отправляется никогда, и узнать об этом по ошибке нельзя, потому что адрес и подпись те же, просто наступает тишина. Подпишитесь на catalog.item_processed: тот же адрес и та же подпись, но событие приходит по каждой позиции, поэтому заливка на 50 позиций даёт до 50 доставок вместо одной, и приёмник должен это выдержать. Признак завершения заливки считайте у себя по своему списку external_ref. Массовое удаление отвечает только полями queued и buried и общего хендла не имеет. Idempotency-Key теперь хранится вместе с хешем тела: точный повтор отвечает 200 с idempotent_replay true, а тот же ключ с другим телом либо на другой каталожной операции даёт 409 idempotency_key_conflict; пространство ключей общее для приёма и массового удаления.
- **2026-08-19** [CHANGED] Каталог разделил состояние товара и открытую операцию, но словарь status остался прежним. Поле status по-прежнему принимает active, pending, deleting, deleted и failed, фильтр GET /catalog со statuses принимает те же пять значений, по умолчанию active — ось статуса не изменилась, разбирать её как раньше можно. Изменилось то, что status стал производным: он вычисляется по фиксированному приоритету failed, затем pending, затем deleting, затем deleted, затем active, где открытая операция проверяется выше состояния позиции. Аддитивно появилось nullable-поле operation со значениями create, update и delete — вид открытой или заброшенной операции; отказ виден как operation вместе с error_code, а точный момент — как failed_at в GET /catalog/errors. Одно следствие стоит запомнить: снятая позиция, для которой уже открыто повторное создание, читается как pending с operation create, потому что создаётся заново, а в каталоге Kaspi её при этом нет, поэтому sellable равен false. Позиция с operation delete не продаётся даже после исчерпания попыток. Код catalog_delete_in_progress удалён: POST /catalog или PATCH атомарно заменяет намерение удаления. В событии catalog.item_processed поле status — тот же производный статус и тот же словарь, что у GET /catalog, значения в push и в списке совпадают всегда.
- **2026-08-19** [NEW] В каждой позиции каталога появились поля operation, error_code, error_message, sellable и in_kaspi_catalog. Поля аддитивные: прежние не изменились, разбирать новые не обязательно. Поле sellable отвечает на вопрос, примут ли позицию в корзину счёта, QR или подписки; у снятой позиции и у той, над которой висит намерение удаления, оно равно false, даже если попытки прекращены. Поле in_kaspi_catalog говорит, есть ли у позиции настоящая номенклатура в каталоге Kaspi: при false счёт создастся, но позиция уедет разовой продажей, и маркировка Нацкаталога в фискальный чек по ней не проводится. На песочной оси этот флаг всегда false, потому что тестовые позиции носят синтетическую идентичность номенклатуры — проверяйте маркировку на боевой оси, false в песочнице поломкой не является. Это разные факты и одним флагом они не выражаются: позиция бывает продаваемой без каталожной идентичности и наоборот. Выводить их из status не нужно, они отдаются готовыми.
- **2026-08-18** [CHANGED] Повторная отправка счёта по номеру больше не создаёт второй реальный счёт при потерянном ответе Kaspi или гонке с синхронизацией. Перед повтором сервер выдерживает паузу, полностью сверяет незавершённую и завершённую истории Kaspi и повторяет создание только тогда, когда обе выборки доказанно полны и не содержат операции. При недоступной, усечённой или неоднозначной истории повтор откладывается. Публичная форма POST /invoices не изменилась. Аддитивно в событии invoice.refunded со статусом failed появились два точных error_code. Код refund_rejected_by_kaspi означает, что Kaspi отклонил возврат по этой операции: отказ может быть временным, поэтому имеет смысл повторить запрос позже, а если не выйдет — выполнить возврат вручную в приложении Kaspi Pay. Сам сервер такой возврат не повторяет: строка сразу финальная, повтор — это ваш новый запрос возврата. Код refund_requires_buyer_confirmation означает, что нужно создать QR-возврат через POST /qr-refunds и показать QR покупателю.
- **2026-08-18** [REMOVED] Режим удаления по фильтру и токены прогона удалены из массового удаления каталога. POST /catalog/bulk-delete теперь принимает только явный список ids или external_refs, до 200 значений. Поля filter, sync_token и run_total больше не участвуют в операции; лишние поля старого клиента игнорируются, но без ids или external_refs запрос ответит 422 catalog_delete_scope_required. Сервер не может безопасно отличить оборвавшуюся заливку от честного сокращения каталога, поэтому за список снятия отвечает интегратор. Используйте dry_run, передавайте полученный would_delete необязательным полем expected_count и ставьте каждый чанк в работу с уникальным Idempotency-Key.
- **2026-08-18** [CHANGED] Песочница отвечает так же, как бой, а код ответа зависит от позиции, а не от режима организации. POST /catalog в песочнице теперь отдаёт 202 вместо 201 и возвращает ту же форму ответа, что боевой контур. У PATCH /catalog/{id} и DELETE /catalog/{id} код ответа выбирается по самой позиции: работа над позицией песочницы закрыта уже в момент ответа и приходит 200, над боевой — принята в работу и приходит 202. Раньше выбор делался по режиму организации, из-за чего в организации, где есть и боевые, и тестовые позиции, ответ не соответствовал тому, что происходило с позицией.
- **2026-08-18** [CHANGED] Удаление тестовой позиции стало логическим, как в бою. Раньше DELETE /catalog/{id} в песочнице стирал позицию физически: она пропадала из выдачи целиком, а повторная заливка того же external_ref заводила новый id, то есть песочница учила поведению, которого в бою нет. Теперь позиция переходит в статус deleted, читается через GET /catalog со statuses равным deleted и восстанавливается повторной заливкой с тем же id — ровно как боевая. Массовое удаление вело себя так и раньше; расхождение двух поверхностей устранено.
- **2026-08-18** [CHANGED] Песочный POST /catalog/scan больше не выдаёт ntin, равный присланному штрихкоду. Это разные идентификаторы, и в бою они не совпадают никогда — сверка вида ntin равен штрихкоду, написанная на тестовом контуре, ломалась на первом же реальном товаре. Ответ песочницы остался детерминированным: по одному и тому же входу приходит один и тот же кандидат. Поле normalized_barcode в этом ответе — эхо вашего значения, так же как в бою: не полагайтесь на то, что оно что-то нормализует.
- **2026-08-18** [CHANGED] Тестовая позиция без НТИН больше не отличается от боевой по типу в корзине. Раньше в песочнице такая позиция уходила в счёт как разовая продажа, а в бою та же позиция идёт как каталожная. На фискальный чек это не влияет: позиция без пары штрихкод и НТИН по-прежнему даёт item_not_fiscal — и в песочнице, и в бою.
- **2026-08-18** [CHANGED] Организация Kaspi после первой привязки за организацией ApiPay закреплена навсегда, а подключение кассира больше не переносит владельца. Подтверждение кода сверяет пару БИН и идентификатора организации Kaspi до того, как сессия станет рабочей. Несовпадение либо пара, уже занятая другой организацией, дают 409 organization_identity_conflict; неполный или смешанный ответ Kaspi — 502 organization_identity_unavailable. У обоих в теле приходят только error и message, отдельного поля error_code нет — разбирайте error, и учтите, что чужие реквизиты ответ не раскрывает. Организация и её действующая сессия при этом не меняются. Ломающее следствие: терминальный исход org_transferred (409) больше не приходит никогда. Если код ветвился на org_transferred, ветку надо переписать на organization_identity_conflict. В статусе подключения добавлено поле attempt_status: значения контрактом не фиксированы, ветвиться по ним не нужно, отсутствие попытки — строка none. Назначить основным подключение, которое ещё не подтвердило организацию или заблокировано, нельзя — приходит 422 connection_identity_unverified. Перенос организации к другому владельцу подключением кассира не делается: если владелец действительно сменился, вопрос решается заявкой в поддержку 77003076512.
- **2026-08-17** [CHANGED] Параметр per_page у GET /catalog/queue и GET /catalog/errors поднят со 100 до 200. Потолок теперь совпадает с GET /catalog, где 200 принимались и раньше. Расхождение было ловушкой: интегратор, листающий каталог по 200, на тех же параметрах получал от ленты приёма 422 с сообщением про предел в 100, то есть не мог прочитать причины отказов своих позиций вообще, хотя они там лежали. Правка чисто расширяющая: минимум 20 и значение по умолчанию 100 не менялись, значения до 100 ведут себя ровно как прежде, а запрос с per_page равным 201 по-прежнему даёт 422. Новых полей, роутов, кодов ошибок и событий вебхука нет.
- **2026-08-16** [CHANGED] Персональная ссылка мерчанту сменила адрес: вместо /kyc/{token} она ведёт на /invite/{token}. Выдавайте клиенту ссылку из ответа как есть и не собирайте адрес у себя из токена — форма ответа не изменилась: invite_url, scope, expires_at. Ссылка перестала быть только анкетной: тем же механизмом мерчанту выдаётся ссылка на подключение кассира Kaspi — он открывает её когда удобно и подключает кассира сам, аккаунт в ApiPay ему не нужен. Код из SMS по-прежнему приходит на телефон кассира: ссылка его не заменяет и сама по себе кассира не подключает. Кассирская ссылка живёт заметно меньше анкетной и гасится сразу после успешного подключения — выдавайте её под конкретный разговор с клиентом, а не про запас. Организацию, где владельцем остаётся сам мерчант, выдача по партнёрскому ключу (POST /api/partner/organizations/{organization}/kyc/invite) не видит: она работает только с организациями, которые вы завели сами. Для таких клиентов ссылку выдают в партнёрском кабинете.
- **2026-08-15** [CHANGED] Удаление каталога по фильтру принимает только закрытый прогон заливки. У POST /catalog появилось поле run_total — сколько позиций прогон пришлёт всего; оно шлётся вместе с sync_token и одинаково во всех запросах прогона. Без него POST /catalog/bulk-delete в режиме filter отвечает 422 catalog_delete_filter_invalid с reason: run_not_closed, а если заявлено больше, чем дошло, — reason: run_incomplete с полями stamped и declared_total. Значение объявляется один раз, в начале заливки: повтор с другим значением даёт 422 catalog_run_total_conflict, новый прогон начинается с нового sync_token. dry_run отбивается теми же проверками. Пока заливка этим токеном ещё идёт, удаление отвечает 409 catalog_run_in_progress — дождитесь окончания и повторите. Режимы ids[] и external_refs[] не затронуты. Если у вас уже настроено удаление остатка через filter.sync_token_not, добавьте run_total в запросы заливки — иначе удаление перестанет проходить. Заодно: список already_queued в ответах обрезан до 200 элементов и является образцом, полное число приходит в новом поле already_queued_count.
- **2026-08-15** [NEW] KYC мерчанта для партнёров: GET /api/partner/organizations/{organization}/kyc и POST /api/partner/organizations/{organization}/kyc/invite. До одобрения анкеты у мерчанта действует суточный потолок счетов, поэтому статус анкеты нужен вам для сопровождения клиента: status принимает значения required, submitted, needs_changes, approved и blocked, рядом идёт производное required_action со значениями submit_profile, wait_review, fix_and_resubmit, none и contact_support — ветвитесь по нему, а не зашивайте у себя маппинг наших статусов. Тот же статус приходит полем kyc_status в карточке организации. Подать анкету за мерчанта нельзя: в ней есть подтверждение о неторговле запрещённым — заверение, которое даёт тот, у кого факты. Вместо этого партнёр получает персональную ссылку (POST .../kyc/invite) и передаёт её клиенту: тот заполняет и подтверждает анкету сам, аккаунт в ApiPay ему не нужен. Сама ссылка показывается один раз, у нас хранится только её хеш; новая выдача отзывает предыдущую, а действующую можно открывать сколько угодно раз до истечения срока — если анкету вернут на доработку, клиент исправит её по той же ссылке. Замечание модератора приходит в поле comment только при needs_changes — передавайте его клиенту дословно. Новый код: invite_invalid — ссылка недействительна или устарела.
- **2026-08-15** [NEW] Новое партнёрское вебхук-событие kyc.status_changed. Приходит на ваш webhook_url, когда модератор принял решение по анкете вашего мерчанта: одобрил, отклонил, вернул на доработку или отменил решение. Payload плоский: event, scope, partner_id, organization_id, external_id, previous_status, kyc_status, comment, source, is_sandbox, timestamp; подпись та же, что у прочих партнёрских событий. Событие описывает ПЕРЕХОД: оба статуса зафиксированы в момент решения, поэтому повторная доставка не догоняет более позднее состояние — актуальное всегда читается из GET /api/partner/organizations/{organization}/kyc. Поле comment приходит только при needs_changes, передавайте его клиенту дословно. Событие приходит только по организациям, которыми вы управляете: мерчант, пришедший по вашей реферальной ссылке, ведёт анкету сам. Ручного повтора такой доставки нет.
- **2026-08-15** [NEW] Синхронизация подписки для интеграторов: PUT /api/partner/organizations/{organization}/subscription. Партнёр, который сам продаёт подписку своим клиентам, объявляет её ТЕКУЩЕЕ состояние — state со значениями active, suspended или cancelled, tier и paid_through, — а мы приводим тариф мерчанта в соответствие. Форма декларативная: повтор того же запроса безопасен, пропущенный вызов чинится следующим, порядок вызовов значения не имеет, ключи идемпотентности не нужны. Подпись обязательна: заголовок X-ApiPay-Signature со значением sha256=hmac_sha256 от строки timestamp.МЕТОД.путь.сырое тело, плюс заголовок X-ApiPay-Timestamp; метод и путь входят в подпись, потому что организация задаётся путём, иначе перехваченный запрос переигрывался бы на другую вашу организацию. Секрет приёма отдельный от секрета ваших исходящих вебхуков и выдаётся ApiPay. Тариф двигается только вперёд и только вверх: более ранняя дата не укорачивает оплаченный срок (ответ unchanged), понижение тира — 409 tier_downgrade_not_allowed. Отзыва тарифа нет: значения suspended и cancelled фиксируются, но срок не трогают — выданный период истечёт сам, поэтому синхронизируйте помесячно. Суточный лимит и название тарифа берутся из ваших договорных условий, а не из запроса: присланные значения возвращаются в блоке ignored рядом с блоком applied. Доступны и созданные вами организации, и заклеймленные, кроме тех, что оплачивают тариф самостоятельно (409 claimed_paying_organization). Новые коды: inbound_sync_disabled, inbound_not_configured, invalid_signature, signature_expired, payload_too_large, unsupported_state, subscription_terms_required, invalid_paid_through, claimed_paying_organization, tier_downgrade_not_allowed, tier_switch_rate_limited.
- **2026-08-15** [CHANGED] Журнал синхронизаций GET /api/partner/subscription-syncs принимает фильтры state, from и to, а неизвестное значение фильтра отклоняет. state — active, suspended или cancelled; from и to задают окно по времени приёма, включительно с обеих сторон, голая дата трактуется в Asia/Almaty (в той же зоне, в которой ответ рендерит даты), а to не может быть раньше from. Опечатка в result или state даёт 422 с именем поля; раньше такой фильтр возвращал пустую страницу. Чужой или несуществующий organization_id по-прежнему даёт пустую страницу, а не отказ: существование чужих записей мы не подтверждаем. Отдельная запись читается через GET /api/partner/subscription-syncs/{sync} — что приняли и почему отказали, включая отказы.
- **2026-08-15** [NEW] GET /api/partner/health отдаёт блок account и раскладку по анкетам. В account приходят mode (sandbox или production), type (referral или operating), api_access_status (none, pending, granted, rejected — условие перехода в production это granted) и tariff_billing_mode (payment — тариф мерчанта оплачивается через tariff/pay или tariff/invoice, assignment — вы назначаете тариф через tariff/assign). Там же появился inbound_sync с полями enabled, secret_configured, secret_hint и accepting: открыта ли дверь входящей синхронизации подписки и тем ли секретом подписывает ваше окружение (secret_hint — последние 4 символа под маской, сам секрет не отдаётся никогда). Проверяйте состояние здесь, а не боевым запросом: при закрытой двери он отвечает 401 или 403, и отличить «не тот секрет» от «приём выключен» по ответу нельзя. Решение принимайте по accepting и не собирайте это условие у себя из двух других полей. В блоке organizations появился kyc — сколько ваших клиентов в каком состоянии анкеты; поимённо тех же клиентов отдаёт фильтр kyc_status у списка организаций. Блок account отдаётся всегда актуальным, в отличие от агрегатов по организациям и вебхукам в том же ответе.
- **2026-08-15** [CHANGED] Партнёрское вебхук-событие tariff.activated описано в спецификации — payload доступен для генерации клиентов. Payload: event, scope, partner_id, organization_id, payment_id, invoice_number, tier, upgrade_from, period_months, amount, expires_at, source, is_sandbox, timestamp; даты в Asia/Almaty. На ваше собственное назначение тарифа (POST /api/partner/organizations/{organization}/tariff/assign) событие не отправляется — это было бы эхо на ваш же запрос, результат приходит синхронным ответом.
- **2026-08-15** [CHANGED] POST /api/partner/organizations с external_id удалённой организации отвечает 409 organization_deleted. Если вы отвязали организацию через DELETE /api/partner/organizations/{organization} и затем создаёте новую с тем же external_id, приходит внятный отказ: удалённая организация не воскрешается, её API-ключи деактивированы. Клиенту, который вернулся, заводите новый external_id. Ключ идемпотентности здесь — тройка из партнёра, external_id и признака «тестовая или боевая», поэтому одно и то же значение в песочнице и в бою живёт независимо, а после вашего перехода в production тестовые строки удалены и значения снова свободны.
- **2026-08-13** [CHANGED] Позицию, которая снимается с продажи, больше нельзя положить в корзину счёта. Пока позиция стоит в статусе deleting, её не принимают POST /invoices, /invoices/qr, печатный QR и создание или обновление подписки — придёт 422, а причина будет в поле errors по ключу cart_items.N.catalog_item_id. В POST /invoices/bulk форма другая: батч остаётся 201, а позиция приходит в invoices[] как failed с error_code catalog_item_not_found. При массовом удалении позиция стоит в статусе deleting долго, и счёт, выставленный в это окно, стал бы фискальным документом на товар, которого к моменту оплаты в кассе уже не будет. Позиция восстановима: пришлите её обычным POST /catalog — удаление отменится, и она снова доступна для счетов. У подписок ветка отдельная: если позиция корзины снимается, очередное списание не проваливается, а ОТКЛАДЫВАЕТСЯ до следующей попытки списания — попытки не сгорают и подписка не уходит в grace period из-за временного состояния. Заодно изменился приоритет сопоставления по штрихкоду: при коллизии (один штрихкод у двух позиций, одна стоит в очереди на удаление) заливка теперь матчится в ЖИВУЮ позицию, а приговорённая уходит по вашему плану — прежний порядок воскрешал приговорённую, не обновлял живую и мог создать в Kaspi второй товар с тем же штрихкодом. Чтобы вернуть позицию из очереди удаления, присылайте её external_ref: этот путь однозначен всегда. PATCH /catalog/{id} по позиции в очереди удаления теперь ОТМЕНЯЕТ удаление (прежнее поведение — 202 без отмены удаления), а в узком окне, когда удаление уже отправлено в Kaspi, приходит 409 catalog_delete_in_progress — повторите через несколько секунд. В песочнице удаление больше не стирает строку физически: она переходит в статус deleted, заводит батч и уважает Idempotency-Key, как на проде, поэтому повторный POST /catalog с тем же external_ref вернёт ТОТ ЖЕ id.
- **2026-08-13** [NEW] Индивидуальные («Custom») условия тарифа: новые поля витрин и код custom_tariff_locked. У мерчанта с договорными условиями тариф — это его базовый тариф плюс свой суточный лимит и своя цена. Поле tier при этом НЕ меняется, значения custom не существует: «кастомность» приезжает отдельными аддитивными полями. GET /api/v1/tariff теперь несёт tier_label, daily_limit и is_custom, а GET /api/v1/tariff/plans — is_custom и can_change_tier; в каталоге у такого мерчанта переписана строка его тарифа (имя, daily_limit, base_price) и планы этого тарифа, остальные остаются прайсовыми. Скидок за длинный период у индивидуальной цены нет: цена периода равна цене месяца, умноженной на число месяцев. Смена тарифа таким мерчантом отдаёт 409 custom_tariff_locked — это касается оплаты из кабинета, счёта юрлицу, счёта на переход и партнёрской оплаты; продление своего тарифа работает как раньше, переход оформляет поддержка. Определяйте состояние заранее по is_custom и can_change_tier, повтор запроса не поможет. Существующие интеграции не ломаются: все поля аддитивны.
- **2026-08-13** [NEW] Массовое удаление позиций каталога: POST /catalog/bulk-delete. Метод POST, а не DELETE, потому что тело у DELETE плохо поддержано HTTP-клиентами 1С. Ключ должен быть выпущен ВЛАДЕЛЬЦЕМ организации: ключ, привязанный к сотруднику, получает 403 catalog_delete_owner_key_required — перевыпустите его от имени владельца. За один запрос выбирается ровно один режим, иначе придёт 422 catalog_delete_scope_required: либо список — ids[] или external_refs[] (до 200 значений, превышение даёт 422 catalog_match_overflow), либо фильтр filter.sync_token_not — «удалить всё, что помечено ДРУГИМ sync_token». Позиции без метки вовсе — заведённые вручную в кассе, пришедшие из каталога Kaspi, залитые до того, как вы начали передавать sync_token, — под фильтр не попадают: остатком выгрузки они не являются. Если снимать нужно и такие, передайте явный filter.include_never_stamped: true; это не разовая процедура первого прогона, непомеченные позиции появляются постоянно. Штрихкоды списком не принимаются: штрихкод не уникален, и одно значение могло бы снять сотни позиций. Порядок работы обязателен: сначала тот же запрос с dry_run: true — он ничего не меняет и возвращает would_delete, sample (до 20 позиций) и already_queued, затем повтор без dry_run с полученным числом в expected_count. В режиме фильтра expected_count обязателен; если каталог изменился между разведкой и командой, придёт 409 catalog_bulk_delete_mismatch с actual_count — повторите разведку, «удалить всё равно» не предусмотрено. Ответ 202 означает «принято в работу», а не «удалено»: позиции снимаются с продажи по одной, а если параллельно идёт заливка каталога — заметно медленнее, обе операции работают в одной очереди. Порядок величин: 500 позиций — около двух часов, несколько тысяч — сутки с небольшим, при параллельной заливке примерно вчетверо дольше. Планируйте окно исходя из этого. Следите за прогрессом по poll_url и ждите вебхук catalog.batch_processed с kind: delete; HTTP-таймаут в расчёте на завершение ставить нельзя. Позиции, уже стоявшие в очереди удаления, приходят в already_queued и в новый батч не переставляются; их точное число едет в аддитивном already_queued_count, который есть во всех ответах, включая dry_run. Позицию, попавшую под удаление по ошибке, можно вернуть: пришлите её обычным POST /catalog — удаление отменится, и если товар ещё не снят в Kaspi, он просто останется на месте. По external_ref этот путь однозначен всегда; по штрихкоду или НТИН он срабатывает, только если тем же штрихкодом не занят другой живой товар — иначе заливка сольётся в него, а приговорённая позиция уйдёт по вашему плану. Если снятие уже прошло, позиция заводится заново: по external_ref вернётся та же строка с тем же id, а у позиции без external_ref, штрихкода и НТИН в каталоге появится новая строка. Другие отказы: 409 catalog_multi_tradepoint (у организации несколько торговых точек), 409 catalog_busy (каталог занят другой операцией, повторите через несколько секунд), 422 catalog_delete_filter_invalid с полем reason — token_never_used означает, что этим sync_token не отмечена ни одна позиция (обычно опечатка), а coverage_too_low, что прогон пометил слишком малую долю живого каталога и, похоже, оборвался: в теле придут числа stamped и visible, лечится повторной полной заливкой, а не подгонкой expected_count. Список значений reason открытый — неизвестное обрабатывайте общей веткой; у catalog_delete_scope_required значения свои: mode_required и expected_count_required. Лимит эндпоинта — 10 запросов в минуту, разведка расходует тот же лимит.
- **2026-08-13** [NEW] sync_token — необязательная метка прогона синхронизации каталога: строка до 64 символов из латиницы, цифр и символов . _ : - принимается в POST /catalog и PATCH /catalog/{id}. Передавайте один и тот же токен во всех запросах одной выгрузки, чтобы затем удалить всё, чего в этой выгрузке не было, через filter.sync_token_not у POST /catalog/bulk-delete. Метка проставляется позициям без изменения updated_at и в ответах листинга не возвращается — храните её у себя. При идемпотентном повторе запроса метка не проставляется.
- **2026-08-13** [CHANGED] Батчи каталога теперь бывают двух видов, и у ответа GET /catalog/batches/{id} появилось поле kind: ingest — заливка, delete — массовое снятие с продажи. Ветвитесь по нему, а не считайте сумму totals вслепую: у ingest работает инвариант total = created + updated + skipped + failed, а у delete — total = deleted + skipped + failed, где created и updated всегда нули. В totals добавлено поле deleted. У GET /catalog/queue появился счётчик deleting — сколько позиций сейчас снимается с продажи; он считается отдельно от total и data, которые по-прежнему описывают только очередь приёма, поэтому пустая очередь приёма не означает, что снятие позиций уже завершено. Изменения аддитивные: прежние поля и их смысл не менялись.
- **2026-08-13** [NEW] Два новых отказа у одиночных операций каталога. DELETE /catalog/{id} может ответить 409 catalog_multi_tradepoint — у организации несколько торговых точек, и удаление из API для неё закрыто; обратитесь в поддержку. POST /catalog может ответить 409 idempotency_key_conflict — переданный Idempotency-Key уже занят операцией другого типа; возьмите новый ключ. Удаление позиции стало устойчивее к временным сбоям: если вызов Kaspi не прошёл по временной причине, строка остаётся в статусе deleting, и повтор выполняется на нашей стороне — отдельного запроса от вас не требуется.
- **2026-08-13** [CHANGED] Отказ invoice_pdf_failed на выписке счёта больше НЕ ретраибелен: было 503, стало 409. Касается POST /api/partner/organizations/{id}/tariff/invoice. Код означает, что счёт УЖЕ выписан: сквозной номер выделен, строки созданы, сорвался только последний шаг — сборка PDF. Прежний код 503 читался как «повторите», а повтор запроса выписывает второй счёт с новым сквозным номером. Что делать теперь: не повторять запрос; сохранить payment_id и number из поля invoice в теле ответа и показать их пользователю; ссылки download_url в этом ответе НЕТ намеренно — файла на диске может не быть, и по токену вернётся 404, поэтому подставлять или угадывать её нельзя. Если счёт нужен срочно, напишите в поддержку: https://wa.me/77003076512, укажите номер счёта. Если у вас был авторетрай на 5xx для этого эндпоинта, снимите его: теперь на этом коде повтор запрещён контрактом.
- **2026-08-13** [NEW] Счёт на ПЕРЕХОД между тарифами: флаг upgrade у POST /api/partner/organizations/{id}/tariff/invoice. При upgrade: true сумма счёта считается как доплата — разница базовых цен тарифов (переход со Старта на Бизнес стоит 15 000 тенге при цене Бизнеса 25 000 в месяц), tier_id — ЦЕЛЕВОЙ тариф, period_months обязан быть 1. С какого тарифа выполняется переход, определяет сервер по активному платному тарифу организации: поля под это в запросе нет намеренно. Новые отказы: 409 no_paid_tariff — переходить не с чего, у организации нет активного платного тарифа (пробный период платным не считается, с него оформляется полный тариф); 400 downgrade_not_supported — целевой тариф не выше текущего: код приходит и на понижение, и когда tier_id совпадает с уже действующим тарифом, понижение оформляется через поддержку; 422 invalid_upgrade_plan — такой ступени перехода нет в прайсе; 422 upgrade_period_not_supported — период у перехода только 1 месяц; 409 upgrade_invoice_pending — неоплаченный счёт на переход у организации уже есть, и он же придёт в поле invoice тела ответа. Неоплаченный счёт на переход у организации может быть только один, и срока жизни у него нет — ждать, пока он истечёт, бесполезно: показывайте пользователю тот счёт, что пришёл в ответе, и не выписывайте второй. Если счёт выписан по ошибке или больше не нужен, напишите в поддержку на WhatsApp https://wa.me/77003076512, чтобы его аннулировали, — после этого можно выписать новый. Активация не автоматическая: как и у обычного счёта, тариф выдаётся после подтверждения поступления средств. После выдачи организация переходит на целевой тариф, а срок действия продлевается на месяц — оплаченные дни не сгорают; актуальные tier и expires_at читайте из GET /api/partner/organizations/{id}/tariff, а не считайте на своей стороне. Изменение аддитивное: без флага цены и поведение прежние. Оплата тарифа по телефону (tariff/pay) перехода по-прежнему не знает — там всегда полная цена плана.
- **2026-08-13** [NEW] Поле upgrade_from — тариф, С которого выполнен переход. Приходит в трёх местах, и лежит в них по-разному: в объекте invoice ответа на выписку счёта; в истории платежей — внутри под-объекта invoice у платежей с payment_method invoice (у оплат по телефону этого под-объекта нет вовсе, искать поле рядом с amount бессмысленно); в вебхуке tariff.activated — отдельным полем верхнего уровня, где у обычного платежа приходит null. Без него счёт на переход неотличим от месяца целевого тарифа со скидкой: сочетание tier: business, period_months: 1, amount: 15000 — это доплата разницы, а не скидка 40 процентов на месяц Бизнеса. Если вы показываете состав платежа своему пользователю, подписывайте такую строку переходом, иначе он будет ждать той же цены в следующем месяце. Поле аддитивное, существующие интеграции не ломает.
- **2026-08-11** [CHANGED] У отказа «паритет каталога» появился машиночитаемый error_code. Раньше отличить эти два отказа от любой другой 422 можно было только по английскому тексту message; теперь в теле рядом с message приходит error_code — catalog_requires_cart_items, когда каталог включён, а корзины в запросе нет, и catalog_not_supported, когда каталог выключен, а корзина передана. Затронуты POST /invoices/qr, POST /static-qr, POST /invoices и PATCH /subscriptions/{id}; в POST /invoices/bulk код catalog_not_supported приходит поэлементно, как и раньше. Создание подписки (POST /subscriptions) корзину тоже требует, но отвечает обычной ошибкой валидации по полю cart_items — error_code там нет, обрабатывайте её отдельно. Изменение аддитивное: тексты message не менялись, статус остался 422, прежний разбор продолжает работать. Ключа errors в теле этих отказов нет — кроме PATCH /subscriptions/{id}, где error_code приходит рядом с errors.cart_items; в остальных случаях отказ описывает состояние организации, а не поле формы, и читать errors.cart_items бессмысленно. Напоминание про порядок: у организации с каталогом корзина обязательна на QR-эндпоинтах и подписках, а счёт по номеру телефона (POST /invoices) такая организация выставляет и одной суммой. Новые error_code: catalog_requires_cart_items, catalog_not_supported
- **2026-08-11** [CHANGED] Отмена QR-счёта отвечает 409 qr_cancel_unsupported. POST /invoices/{id}/cancel для счёта с is_qr_token: true отклоняется сразу: отмена QR-счёта не поддерживается, статус счёта не меняется и в Kaspi ничего не уходит. В теле ответа приходит expires_at — момент, после которого QR перестанет быть оплачиваемым; если там null, ориентируйтесь на вебхук перехода в expired, а не на локальный отсчёт. Если вы полагались на прежний ответ 202, учтите: отмены не происходило и QR оставался оплачиваемым до конца окна на скан, поэтому считайте такой счёт активным до статуса expired. Вместо отмены делать ничего не нужно — QR гаснет сам; нужен другой счёт, выставьте новый: QR-счета сосуществуют и старый не мешает. Телефонные счета отменяются как прежде (202 и асинхронная отмена), QR-счёт в песочнице тоже отменяется (200). Новый error_code: qr_cancel_unsupported
- **2026-08-11** [CHANGED] Сумма счёта на номер телефона — только целые тенге, дробная отбивается сразу с 422 amount_must_be_whole_tenge. Касается POST /invoices (и суммы amount, и итога корзины cart_items) и POST /invoices/bulk, где проверка идёт поэлементно: позиция с тиынами приходит в invoices[] как failed с этим error_code, остальные позиции создаются нормально. Сумма проверяется до отправки счёта — телефонный счёт принимает только целые тенге; если вы отправляли суммы с тиынами, такие счета больше не создаются: отказ приходит сразу в ответе на создание, статус error по ним больше не появляется. Проверяется итог после скидок: discount_percentage считается построчно, поэтому даже при целых ценах итог может стать дробным (999 тенге со скидкой 10 процентов дают 899.10) — округляйте цены позиций или процент скидки. Минимальная сумма телефонного счёта — 1 тенге. На QR ограничения нет — POST /invoices/qr принимает суммы с тиынами, поэтому счета с копейками выставляйте через QR. Проверьте автоплатежи и печатные листы: списание по подписке и оплата с печатного листа по номеру телефона выставляются телефонным счётом, поэтому дробная сумма подписки или дробный итог её cart_items даёт статус error с этим error_code в вебхуке invoice.status_changed при каждом списании, а печатный лист с дробной суммой не удастся выставить покупателю по номеру. Новый error_code: amount_must_be_whole_tenge
- **2026-08-10** [CHANGED] GET /cashbox/reconciliation работает только по смене: параметры mode и date удалены, shift_id стал обязательным. Из ответа убраны поля comparable, verdict и блок comparison — разница между нашими счетами и кассой не вычисляется, потому что Kaspi не отдаёт безналичную часть выручки. Дневные наличные по-прежнему отдаёт GET /cashbox/summary
- **2026-08-10** [NEW] Чек Kaspi по счёту: GET /api/v1/invoices/{id}/receipt и поле kaspi_qr_link. Ручка отдаёт три ссылки на чек Kaspi по оплаченному счёту, чтобы отдать чек покупателю: receipt_link — страница чека (секрета не несёт), download_link — прямой PDF и share_link — ссылка для покупателя (обе содержат секретный hash, см. ниже); плюс sale_date и fetched_at. Это чек Kaspi по оплате счёта; фискальные чеки за наличные и POS другого банка — отдельный раздел /receipts (Kaspi OFD), к этой ручке он отношения не имеет. Ответ асинхронный: первый вызов отвечает 202 с телом status=pending и poll_after, дальше поллите тот же URL до 200 со status=ready. Интервал берите из poll_after, а не из своей константы. Готовый чек кэшируется, поэтому повторный запрос по тому же счёту отвечает сразу. Чек существует только у счёта в статусе paid или partially_refunded — иначе 409 receipt_not_available_for_status; тот же код приходит, если у оплаченного счёта ещё нет числового идентификатора Kaspi. Новые error_code: receipt_not_available_for_status (409), receipt_rate_limited (429) и receipt_unavailable (503); последний повторяем, обычно помогает повтор через минуту. Кроме них ручка отдаёт 409 kaspi_session_expired и 409 kaspi_session_unavailable, когда кассир по счёту требует переподключения или временно недоступен. У ручки собственный лимит запросов. Внимание к двум ссылкам: download_link и share_link содержат параметр hash, который открывает чек конкретной сделки любому, у кого есть строка. Обращайтесь с ними как с секретом — не пишите в логи, не кладите в URL своих страниц и не показывайте посторонним. Выданную ссылку мы отозвать не можем: если она утекла, закрыть доступ нечем. Вебхука на это событие нет — запрашивайте чек этой ручкой после того, как счёт перешёл в paid. Отдельно, вторым изменением, объекты счёта в GET /invoices и GET /invoices/{id} получили вычисляемое поле kaspi_qr_link — ссылку вида https://kaspi.kz/qr/pay?tranId=QR… , из которой рисуется QR для оплаты счёта сканированием. Поле приходит null, пока Kaspi не присвоил идентификатор (статус processing), и всегда null в песочнице; не путайте его с qr_token_url — то отдельный механизм QR-token счетов. В песочнице ручка чека отвечает сразу, а ссылки помечены sandbox=1 и никуда не ведут. Изменение аддитивное: существующие поля и эндпоинты не затронуты.
- **2026-08-10** [NEW] Раздел Касса: девять эндпоинтов /api/v1/cashbox/* и два вебхук-события. Появились кассовые операции поверх кассы Kaspi. Чтения: GET /cashbox/summary — сводка по наличным за календарный день в зоне Asia/Almaty (остаток, внесения, изъятия, продажи и возвраты наличными, флаги auto_withdrawal и available_cashbox_actions); GET /cashbox/shifts — список смен за окно с обязательными date_from и date_to глубиной не более 31 дня, каждая смена несёт id (он же kaspi_shift_id), shift_number, is_current, total_income и total_income_raw; GET /cashbox/reconciliation — сверка наших счетов с кассой; GET /cashbox/settings — текущие тумблеры; GET /cashbox/shifts/{shift}/report — временная подписанная ссылка на PDF-отчёт по смене с полем expires_at, срок жизни ссылки около пятнадцати минут. Запись: PUT /cashbox/settings/auto-close и PUT /cashbox/settings/auto-withdrawal переключают автозакрытие смены и автоизъятие наличных и отвечают changed и new_value, где changed=false означает, что живое значение на кассе уже равнялось запрошенному; POST /cashbox/shifts/close закрывает смену асинхронно и отвечает 202 с id, status=pending и poll_url. Итог закрытия узнавайте поллингом GET /cashbox/operations/{id} до статуса completed или failed либо вебхуками cashbox.shift_closed и cashbox.shift_close_failed (дедуплицируйте по паре event и operation.id). Ключ идемпотентности client_operation_id обязателен, от 8 до 191 символа из набора A-Z a-z 0-9 точка подчёркивание двоеточие дефис, уникален на организацию. Повтор с тем же ключом даёт 409 cashbox_duplicate_operation с operation_id уже принятой операции — по нему можно продолжить поллинг. Ключ не освобождается даже после failed, поэтому повторное закрытие отправляйте с новым client_operation_id; resolution.safe_to_retry при этом подсказывает, безопасен ли автоматический повтор. Состояние смена уже закрыта трактуется как успех. Про сверку важно понимать главное: она показывает обе цифры рядом и причины расхождения, но не доказывает их равенства и разницу не вычисляет. Итог смены в кассе — единая сумма, продажи наличными и продажи мимо ApiPay в ней не выделены, поэтому полей verdict, comparable и delta в ответе нет. Сверка идёт по одной смене: shift_id обязателен, смену нужно предварительно получить через GET /cashbox/shifts. Структурные причины перечислены в discrepancies с полями code, source и message; поле amount там всегда null. Новые error_code: cashbox_disabled, cashbox_kkm_unknown, cashbox_no_open_shift, cashbox_shift_already_closed, cashbox_shift_not_found, cashbox_operation_not_found, cashbox_duplicate_operation, cashbox_busy, cashbox_operation_failed, cashbox_unavailable, cashbox_report_unavailable, cashbox_toggle_in_progress, cashbox_toggle_unavailable. Кассовые ручки живут под собственным лимитом запросов, при его превышении приходит 429; GET /cashbox/operations/{id} под этот лимит не попадает. Изменение аддитивное: существующие эндпоинты не затронуты.
- **2026-08-10** [REMOVED] Параметр with_summary у GET /invoices удалён, сверка переехала в Кассу. Раньше with_summary=1 добавлял в ответ листинга объект summary с итогами по всей выборке. Теперь параметр не распознаётся: запрос с ним отвечает 200 и просто не содержит summary — 422 при этом не приходит, поэтому интеграция, читающая ответ без проверки, тихо получит пустые итоги вместо ошибки. Схемы InvoiceListSummary и InvoiceMoneyGroup из спецификации убраны. Замена — GET /api/v1/cashbox/reconciliation: блок ours даёт ту же математику по нашим счетам (sales с refunded_later, refunds, net_amount, coverage, period) и дополнительно показывает рядом цифру кассы Kaspi. Что учесть при переносе: окно задаётся не произвольными date_from и date_to, а конкретной сменой: shift_id обязателен, и смену сначала нужно получить через GET /cashbox/shifts, а дневные наличные отдаёт отдельный GET /cashbox/summary; поле даты всегда paid_at и не настраивается; фильтры search, status[] и api_key_ids не поддерживаются; групп cancelled, expired и pending в ours нет — окно режется по дате оплаты, а у неоплаченных счетов её не существует. Если нужны именно эти группы за произвольное окно, считайте их по листингу GET /invoices с фильтром status[]. Остальная форма ответа GET /invoices не менялась, обязательных полей в запросах не добавилось.
- **2026-08-09** [CHANGED] В окнах фильтров минуты и секунды стали необязательными: date_from=2026-08-03 11 валиден наравне с 2026-08-03 11:00. Принимаются все четыре формы — 2026-08-01, 2026-08-01 11, 2026-08-01 11:30 и 2026-08-01 11:30:15, по-прежнему можно через T и с явным офсетом +05:00 или Z (в query-строке плюс кодируется как %2B). Дробные секунды тоже принимаются — ровно то, что отдаёт Date.toISOString() в браузере. Работает везде, где окно уже принимало время: GET /invoices, GET /invoices/stats, GET /refunds, GET /refunds/stats, GET /receipts, GET /catalog/errors и выгрузка счетов из кабинета. Час — это момент, а не период: date_to=2026-08-01 18 означает 18:00:00, а не конец восемнадцатого часа; до конца суток растягивается только голая дата (date_to=2026-08-01 = 23:59:59). Изменение аддитивное: голая дата, H:i и H:i:s дают ровно те же границы, что и раньше. Зона границ прежняя — Asia/Almaty, невозможные значения вроде 2026-08-01 25 по-прежнему дают 422
- **2026-08-09** [NEW] Новый терминальный код org_transferred (409) на POST /connections/{connection}/auth/verify-otp. Он означает, что организация этого кассира уже заведена в ApiPay и по итогам подтверждения закреплена за вашим аккаунтом, то есть операция удалась. Рядом с error и message в теле приходят organization_id и organization_name — это организация, в которой теперь надо работать: кассира подключайте уже в ней (новый init, затем send-phone и verify-otp по её connection), а список организаций перечитайте из GET /users/me. Повторять OTP на прежнем подключении бессмысленно: организация, из которой шёл запрос, осталась пустой. Обрабатывайте код хотя бы общей веткой — без обработки мерчант увидит непонятную ошибку в момент, когда всё получилось. Формы остальных ответов verify-otp и шага send-phone не менялись
- **2026-08-09** [CHANGED] У catalog_block_reason появилось четвёртое значение — no_tradepoint. Поле приходит в ответе POST /connections/{connection}/auth/verify-otp и объясняет, почему каталог товаров не заработает: no_tradepoint означает, что код торговой точки организации в Kaspi определить не удалось, — раньше такие организации получали null. Форма поля не изменилась (строка или null), остальные значения (idn_conflict, no_idn, no_kaspi_org_id) означают ровно то же, что и раньше. Во всех случаях каталог остаётся пустым до вмешательства поддержки, поэтому показывайте мерчанту сообщение вида каталог недоступен, напишите в поддержку. Список значений открыт: обрабатывайте неизвестный код общей веткой, иначе следующее добавленное значение обрушит плашку
- **2026-08-09** [NEW] Новый тариф pro_max — четвёртая ступень над pro: до 600 счетов в сутки, 90 000 ₸ в месяц. Каталог тарифов GET /api/v1/tariff/plans теперь отдаёт четыре тарифа вместо трёх и шестнадцать планов вместо двенадцати: к start (до 30 счетов в сутки, 10 000 ₸), business (до 100, 25 000 ₸) и pro (до 300, 60 000 ₸) добавился pro_max (до 600, 90 000 ₸). Лесенка скидок за период та же: pro_max за 1 месяц — 90 000 ₸, за 3 месяца — 256 500 ₸ (−5%), за 6 месяцев — 486 000 ₸ (−10%), за 12 месяцев — 918 000 ₸ (−15%). Значение pro_max принимают поля tier_id при оплате тарифа из кабинета и в Partner API (POST /api/partner/organizations/{id}/tariff/pay и .../tariff/invoice). Внимание тем, кто держит перечень тарифов у себя: жёстко зашитый список из трёх значений теперь неполон, а валидация «только start, business или pro» на вашей стороне отклонит существующий тариф. Позиционный разбор каталога тоже перестал быть верным — последний элемент списка больше не тот, что был вчера, поэтому берите тариф по id, а не по индексу. Ни один тариф не безлимитный: у pro_max есть суточный потолок 600 счетов, и ведёт себя он так же, как потолки младших тарифов. Форма объектов в ответах не менялась, новых полей, error_code и вебхук-событий нет.
- **2026-08-08** [CHANGED] qr_expires_at в ответе POST /invoices/qr совпадает с реальным окном на скан. Это момент, до которого QR ещё можно отсканировать; значение стало ближе, поэтому таймер на экране кассы, построенный по этому полю, пересчитается сам. Форма ответа не изменилась — изменилось только значение поля. Не зашивайте длительность окна константой, считайте её как qr_expires_at минус текущее время: окно задаёт Kaspi. Окно ограничивает только скан: после того как покупатель отсканировал QR и попал на экран оплаты, операция живёт дольше, поэтому оплата, начатая под конец окна, завершается уже после qr_expires_at — ориентируйтесь на вебхуки и на поле status, а не на локальный отсчёт. Картинка qr_image_url живёт qr_expires_at плюс 60 секунд, поэтому вместе со значением сдвинулся и её срок. В песочнице окно то же, что в рабочем режиме
- **2026-08-08** [NEW] Повторная привязка кассира к другой вашей организации требует подтверждения: 409 duplicate_cashier_confirm и флаг confirm_duplicate. Один номер кассира работает в одной организации за раз, поэтому, если номер уже подключён к другой организации того же владельца (в том числе неактивным подключением или в удалённой организации), POST /connections/{connection}/auth/send-phone отвечает 409 с телом error, message, can_override и existing_organization с полями id, name, connection_status и invoices_count. Это не блокировка: повторите тот же запрос с confirm_duplicate: true, и он пройдёт. Штатная переавторизация не затронута — если это то же самое подключение и вы подключаете кассира заново, гейт не срабатывает вовсе. Флаг действует только на организации того же владельца, а connection_status — это статус подключения (active, inactive, pending, error), а не организации. Формы успешных ответов и шаги init и verify-otp не менялись
- **2026-08-08** [NEW] Вход в кабинет: регистрация номером, который уже является кассиром, приостанавливается до подтверждения. Номер WhatsApp для входа в ApiPay и номер кассира Kaspi — разные роли, поэтому POST /auth/whatsapp/verify-otp в этом случае отвечает 409 с error cashier_phone_signup и полями can_override, confirmation_token, owner_masked_phone и existing_organization, а аккаунт не создаёт. В шаблонной ветке входа попытка переходит в статус needs_confirmation, и те же поля приходят в GET /auth/whatsapp/check-status/{token}. Создание завершает новый POST /auth/whatsapp/confirm-signup с телом confirmation_token — он отдаёт тот же конверт, что и verify-otp (user и is_new_user). Токен одноразовый и живёт 15 минут, повторное использование даёт 410 confirmation_expired. На POST /auth/whatsapp/request-otp ничего не изменилось: подсказка появляется только после ввода кода или нажатия кнопки, то есть после доказательства владения номером. Номер владельца отдаётся маской, организация под управлением партнёра не раскрывается вовсе, а приглашённых менеджеров пауза не затрагивает
- **2026-08-03** [CHANGED] POST /auth/whatsapp/request-otp: 6-значный код пробуется только у тех, кто недавно подтверждал вход, остальным сразу уходит шаблон с кнопкой. Запись от 2026-08-02 о том, что код пробуется всегда, описывала временную меру — она снята. Контракт не изменился: flow otp и flow template возвращаются в тех же формах, поля polling_token, expires_in и already_sent, а также POST /auth/whatsapp/verify-otp и GET /auth/whatsapp/check-status/{token} не менялись. Коды 410 window_expired и 500 whatsapp_gateway_error на этом эндпоинте по-прежнему не отдаются: если окно диалога закрылось, переключение на шаблон происходит внутри того же запроса. Практическая разница только в распределении — доля ответов flow template вырастет, а сам request-otp отвечает быстрее
- **2026-08-02** [NEW] Отчёт по смене: фильтры по дате принимают время, у GET /invoices появилась сводка summary. Раньше date_from и date_to принимались строго как Y-m-d и разворачивались в календарные сутки, поэтому смену, переходящую через полночь (с 11:00 до 03:00), выразить было нельзя, а сверить выручку — тем более: сумм в ответе не было ни одной. Теперь обе границы принимают Y-m-d H:i[:ss] — через пробел или T, с явным офсетом (+05:00, Z) или без него; в query-строке + кодируется как %2B. Зона границ без офсета — Asia/Almaty, а не UTC. Голая дата ведёт себя как раньше: это полные календарные сутки мерчанта, date_to растягивается до 23:59:59, поэтому существующие интеграции править не нужно. Обе границы включительные. Невалидный формат (next monday, 2026, 01.08.2026) даёт 422 на поле. То же окно со временем понимает GET /refunds — там оно режется по времени операции возврата. В GET /invoices/stats время принимают start_date и end_date: имена параметров там другие. Выгрузка счетов из кабинета (CSV, XLSX, PDF) окно со временем тоже понимает. Новый параметр date_field=created_at|paid_at (по умолчанию created_at) задаёт, по какому времени резать окно: терминал раскладывает операции по времени ОПЛАТЫ, поэтому для сверки кассы берите paid_at. Счёт, выставленный в 02:55 и оплаченный в 03:05, при paid_at уйдёт в следующую смену — так же, как в терминале, — а при created_at останется в закрывающейся. При paid_at неоплаченные счета из выборки отсеиваются. Новый параметр with_summary=1 добавляет в ответ листинга объект summary с итогами по ВСЕЙ выборке, а не по странице: sales (принятые деньги — paid плюс partially_refunded, по полной сумме счёта), refunds (завершённые операции возврата, совершённые в этом окне), net_amount = продажи минус возвраты, плюс cancelled, expired, pending и period с эхом применённых границ. Если границы не заданы, period приходит пустым — читайте его с проверкой, поля from и to там равны null. Суммы в summary — строки с двумя знаками, как amount у счёта; net_amount может быть отрицательным. Сводка зеркалит выборку списка и своих отсечек не добавляет: всё, что видно в списке, попадает и в summary. Учтите два свойства блока refunds: он считается по времени ОПЕРАЦИИ возврата, поэтому возврат может относиться к счёту вне выборки, и по той же причине фильтры search и status[] к нему не применяются — подавать эту цифру как «возвраты по отфильтрованным счетам» нельзя. Расхождение объясняет sales.refunded_later — сколько по счетам самой выборки вернули когда-либо. По умолчанию with_summary выключен — включайте его для отчётов и сверки, а не для частого поллинга листинга. Форма ответа без флага не изменилась. Отдельно: status[] теперь принимает partially_refunded — раньше это значение давало 422, и фильтр по оплаченным терял каждый частично возвращённый счёт. Для сверки кассы фильтруйте по обоим статусам сразу (status[]=paid&status[]=partially_refunded) либо берите summary.sales — там они уже сложены. По той же причине paid_amount в GET /invoices/stats для сверки не годится: он считает только paid и частично возвращённые счета не включает. В GET /invoices/stats поля period.start и period.end теперь отдаются в ISO-8601 с офсетом мерчанта (2026-08-03T11:00:00+05:00) вместо голой даты — на окнах со временем голая дата врала. Новых error_code и вебхук-событий нет, обязательных полей в запросах не добавилось.
- **2026-08-02** [NEW] Новый error_code tariff_limit_reached (HTTP 429): лимит счетов по оплаченному тарифу теперь может ограничивать создание счетов. Раньше дневной лимит тарифа не отклонял ни одного запроса — превышение только показывалось в кабинете. Теперь отказ приходит в двух случаях: систематическое превышение лимита и исчерпанный бюджет у организаций, переведённых на помесячный подсчёт (30 × дневной лимит на 30-дневный блок). Разовый всплеск продаж не блокируется — превысить лимит в отдельный день по-прежнему можно. Ответ: error, error_code, message, retry_after_seconds и meta с полями mode, limit, used, reset_at; плюс заголовок Retry-After. meta.mode равен daily либо monthly, meta.reset_at — момент обнуления счётчика, повторять запрос раньше бессмысленно. Затрагивает POST /invoices, POST /invoices/bulk, POST /invoices/qr и автосписания по подпискам, созданным через API (те просто пропускают цикл, не сдвигая дату следующего списания). В POST /invoices/bulk отказ приходит поэлементно и несёт только error_code и message, без Retry-After и meta — как и остальные cap-лимиты там. Счета, созданные без API-ключа (из кабинета), в лимит не входят и не отклоняются; счета песочницы тоже. Ограничение снимается переходом на тариф выше сразу после оплаты. Текущий расход и состояние ограничения видны в GET /users/me, блок daily_usage: mode, period_limit, period_used, period_reset_at, hard_limited. Изменение аддитивное: новых обязательных полей в запросах нет.
- **2026-08-02** [CHANGED] Тариф Pro больше не безлимитный: у него дневной лимит 300 счетов. В каталогах тарифов (GET /tariff/plans, GET /billing/plans, GET /partner/tariff-plans, GET /partner/tariff-catalog) поле daily_limit у тарифа pro было null и стало 300. Стоимость 60 000 тенге в месяц и состав возможностей не менялись — публичное описание тарифа Pro и раньше указывало до 300 счетов в день. Если ваш код трактует daily_limit = null как безлимит, для Pro эта ветка больше не срабатывает. Значение null из схемы не убрано, обрабатывать его по-прежнему нужно — трактуйте его как объём согласуется индивидуально, а не как ноль. Что означает достижение лимита — см. запись про tariff_limit_reached от той же даты. Формы ответов, роуты, error_code и вебхук-события не менялись.
- **2026-08-02** [CHANGED] POST /auth/whatsapp/request-otp: сначала всегда пробуется 6-значный код в WhatsApp, шаблон с кнопкой стал запасным вариантом, и переключение между ними происходит внутри одного запроса. Раньше ветка выбиралась по внутреннему признаку: пока признака нет, слался только шаблон, а код был недоступен. Теперь сначала идёт сообщение с кодом (flow = otp), и если 24-часовое окно диалога WhatsApp закрыто, тот же ответ приходит как обычный flow = template с polling_token. Два ответа этого эндпоинта исчезли: 410 с error = window_expired и полем fallback_to_template, а также 500 с error_code = whatsapp_gateway_error — повторять запрос после них больше не нужно, обработку этих веток на экране входа можно снять. Форма успешных ответов и поля flow, polling_token, expires_in, already_sent не менялись; POST /auth/whatsapp/verify-otp и GET /auth/whatsapp/check-status/{token} тоже. В справочник добавлены уже существовавшие значения поля error: max_attempts_exceeded (429, исчерпаны 3 попытки ввода кода), rate_limit_ip и rate_limit_phone (429, лимиты перебора — теперь проверяются на обеих ветках отправки). Новых error_code нет.
- **2026-08-01** [CHANGED] POST /catalog/upload-image: принимаются только JPEG и PNG, тип определяется по содержимому, добавлены ответы 413, 429 и 500. Раньше эндпоинт принимал jpg, jpeg, png, gif, bmp, svg и webp и определял формат по имени файла и Content-Type. Теперь тип определяется по содержимому файла, а допустимы только JPEG и PNG: файл с расширением .png, но иным содержимым, отклоняется как 422 invalid_file_type; gif, webp, bmp и svg теперь тоже 422 — если вы грузили эти форматы, конвертируйте их в JPEG или PNG на своей стороне. Дополнительно проверяются габариты: стороны 64–6000 пикселей, площадь не больше 12 мегапикселей, вне диапазона — 422 image_rejected. Порог размера снижен с 10 до 6 МБ; файл больше 6 МБ теперь отдаёт 413 file_too_large (раньше это был 422). Появились 429 (лимит 60 запросов в минуту и 2000 в сутки на ключ; первичное наполнение каталога в порог не упирается) и 500 image_processing_unavailable — временная недоступность обработки, изображение при этом не сохранено, image_id не выдан, повторять запрос безопасно. Изображение перекодируется на нашей стороне (приводится к JPEG не больше 512 на 512), поэтому байты на выходе не совпадают с загруженными, а дедупликация по MD5 считается от результата. Практическое следствие для тех, кто грузил раньше: у части старых изображений в базе лежит MD5 оригинала, поэтому повторная загрузка такого файла один раз не найдёт совпадения и создаст новый image_id — прежний продолжает работать, чистить ничего не надо. Поля запроса и форма успешного ответа с image_id не менялись.
- **2026-07-28** [NEW] Внутренняя заметка мерчанта internal_comment и новый метод PATCH /invoices/{id}. У счёта появилось поле internal_comment длиной до 255 символов — заметка для себя: кто это и за что. В Kaspi она не передаётся, плательщик её не видит, в чек и на печатный лист не попадает — в отличие от description, который уходит в Kaspi как комментарий счёта и становится наименованием позиции в QR-чеке. Заметка принимается при создании во всех трёх точках: POST /invoices, POST /invoices/qr (лимит description в 100 символов к заметке не относится — у неё 255) и поэлементно в POST /invoices/bulk. Возвращается в GET /invoices и GET /invoices/{id}, ищется подстрокой через search, попадает в выгрузку CSV, XLSX и PDF из кабинета и в payload вебхуков invoice.status_changed и invoice.qr_scanned — только если поле не null, поэтому у тех, кто его не использует, форма вебхука не меняется. В invoice.refunded заметки нет. Новый метод PATCH /invoices/{id} с телом internal_comment меняет заметку в любом статусе, включая paid и expired; null или пустая строка стирают её; тело без ключа internal_comment даёт 422; ответ 200 — счёт целиком, той же формы, что GET /invoices/{id}. Метод закрыт тарифным гейтом: без активной подписки вернётся 403 tariff_inactive. Правка заметки вебхук не порождает — новое значение уедет со следующим штатным событием по этому счёту. Изменение аддитивное.
- **2026-07-28** [REMOVED] Из объекта счёта убрано поле client_comment. Поле присутствовало в ответах GET /invoices и GET /invoices/{id} и всегда было null: записать в него что-либо не позволял ни один эндпоинт — колонка осталась от нереализованной идеи комментария клиента к счёту. Теперь ключа в ответе нет вовсе. Значения поле не несло, поэтому логика на его основе невозможна; если ваш парсер требует ключ обязательным — сделайте его опциональным. Из выгрузки счетов в кабинете (CSV, XLSX, PDF) по той же причине исчезла вечно пустая колонка Комментарий. Замена есть: внутренняя заметка мерчанта internal_comment — она реально пишется и редактируется. Другие поля, роуты, error_code и вебхук-события не менялись.
- **2026-07-28** [CHANGED] Отказ авторизации кассира больше не объясняет причину: код org_claim_conflict заменён нейтральным cashier_unavailable (409), плюс на send-phone появился новый rate_limited (429). POST /connections/{id}/auth/send-phone и обе партнёрские ручки POST /partner/organizations/{id}/kaspi-auth/send-phone и verify-otp в этом состоянии отдают 409 с error = cashier_unavailable; состояние постоянное, повтор не поможет. Что делать интегратору: заменить в логике обработки org_claim_conflict на cashier_unavailable; различить причину программно больше нельзя — показывайте пользователю нейтральный текст и отправляйте в поддержку. Дополнительно на send-phone появился 429 rate_limited — срабатывает при слишком частых попытках подключения кассира, обычный онбординг в порог не упирается. Окно суточное: Retry-After (а на партнёрской поверхности ещё и поле тела retry_after_seconds) содержит секунды до обнуления счётчика, обычно это часы, и повтор раньше только жжёт попытки. В счётчик входят только новые номера: кассир, который уже был подключён к любой вашей организации, пробой не считается, поэтому переавторизация рабочей точки под лимит не попадает. Что не изменилось: not_cashier и not_registered (422) остались как есть — это статус номера в Kaspi Pay. Новых полей, роутов и вебхук-событий нет.
- **2026-07-28** [CHANGED] Тестовая (sandbox) организация партнёра больше не принимает реального кассира и реальные деньги — новый 409 test_organization. Организация, созданная партнёром в режиме sandbox (флаг is_test, снять его нельзя — он неизменяемый), задумана как временный полигон: переход партнёра в рабочий режим удаляет её целиком. Такие попытки отбиваются до любых действий: 409 с error = test_organization на все три шага авторизации кассира в Public API и на POST /partner/organizations/{id}/tariff/invoice в Partner API. Состояние постоянное, повтор не поможет: боевого мерчанта заводят боевой организацией после перевода партнёра в рабочий режим. Что не изменилось: мок-контур песочницы работает как раньше (магические номера и OTP на POST /partner/organizations/{id}/kaspi-auth, мгновенная мок-активация POST /partner/organizations/{id}/tariff/pay для тестовой организации), боевые организации не затронуты вовсе, новых полей, роутов и вебхук-событий нет.
- **2026-07-28** [CHANGED] Лимит тестовых счетов в песочнице поднят с 500 до 1000 на организацию. Превышение по-прежнему отдаёт error sandbox_invoice_limit; освободить место можно очисткой песочницы в кабинете. Изменение обратно совместимое: интеграции, рассчитанные на 500, продолжают работать.
- **2026-07-28** [CHANGED] Тестовый период выдаётся на организацию, а не один раз на владельца аккаунта. Каждая новая подключённая организация получает свои 3 дня рабочего режима; повторное подключение кассира той же организации триал не открывает — в том числе после отвязки и повторного онбординга.
- **2026-07-27** [NEW] Появились две новые ветки API. Печатный QR под сделку (POST /static-qr, GET /static-qr, GET /static-qr/{id}, DELETE /static-qr/{id}) — лист с QR, привязанный к одной сделке: покупатель наводит камеру телефона, попадает на страницу-мост и платит в Kaspi, а счёт материализуется в момент скана в контексте вашей организации. В отличие от QR-счёта (POST /invoices/qr), который живёт минуты и создаётся под покупателя у кассы, печатный лист висит на бумаге месяцами. Тело создания — как у счёта (amount ЛИБО cart_items, description до 100 символов, external_order_id), отсутствие подключённого кассира созданию не мешает. Отдельного вебхука у листа нет — оплата приходит обычным вебхуком счёта. Поле token в ответе — адрес самого листа: он зашит в QR и в ссылку для печати, поэтому открыть страницу оплаты сможет любой, у кого есть лист; для ручного ввода людям печатается short_code. Вторая ветка — возврат по QR (POST /qr-refunds, GET /qr-refunds/{id}, GET /qr-refunds/{id}/operations, GET /qr-refunds/{id}/operations/{ref}, POST /qr-refunds/{id}/execute, POST /qr-refunds/{id}/simulate): Kaspi возвращает деньги только после того, как покупатель подтвердит возврат сканированием возвратного QR, и лишь затем вы видите список его операций. Обычный POST /invoices/{id}/refund не менялся и работает как раньше. У сессии два срока: сам QR живёт минуты, и отдельно ограничено время на выбор операции после опознания покупателя, поэтому опрашивайте GET /qr-refunds/{id} и после customer_identified. Идентификаторы операций и позиций (ref) непрозрачны и привязаны к сессии — не парсите их. execute синхронный; amount и items взаимоисключимы. Вебхуки: qr_refund.identified, qr_refund.completed, qr_refund.expired. Изменение аддитивное.
- **2026-07-22** [CHANGED] Ошибка 403 tariff_inactive (нет действующей подписки на ApiPay) теперь закрывает ВСЕ платные операции, а не только создание счёта и чека: отмену и возврат счёта, весь изменяющий каталог (создание, изменение, удаление, повтор, scan, sync, загрузка изображения), создание, изменение и возобновление подписок, POST /organizations/{id}/sync и проверку номера клиента. Грейс-период отменён — блокировка наступает сразу после expires_at, а не через 3 дня. В теле 403 добавлено поле expires_at (ISO 8601) — когда истёк тариф; null, если тариф не оформлялся ни разу. Продолжают работать: все операции чтения (GET), оплата тарифа, управление ключами, менеджерами и настройками организации, подключение и переподключение кассира, проверка статусов счетов, а также приостановка и отмена подписок. Отдельно: POST /invoices/bulk при неактивном тарифе отбивает весь запрос 403, а не возвращает 201 с tariff_inactive в поэлементном invoices[]. Песочница и тестовые организации не блокируются вовсе — тариф там не требуется.
- **2026-07-14** [NEW] Появился GET /receipts — история фискальных чеков организации: пагинированный список (свежие сверху), плоская пагинация {current_page, data, total}, элемент списка той же формы, что отдаёт GET /receipts/{id}. Фильтры: status (pending | issued | failed), payment_type (3 — наличные, 5 — POS другого банка), invoice_id, окно дат from / to. Чтение истории НЕ гейтится kill-switch'ем fiscal_receipts_disabled (он про выбивание чека): даже с выключенной фичей список уже выбитых чеков остаётся доступен. Выборка скоупится режимом организации — боевая организация не видит чеки песочницы, и наоборот. per_page — от 1 до 100 (по умолчанию 20). Окно дат from / to трактуется в Asia/Almaty (+05:00): голая дата (2026-07-14) — это календарные сутки мерчанта, а to включает весь день целиком; дата-время с явным смещением берётся как есть. Компенсировать смещение на клиенте не нужно. То же правило окна дат теперь действует и у GET /catalog/errors. Изменение аддитивное.
- **2026-07-13** [CHANGED] Песочница фискальных чеков зеркалит рабочий режим: отказ 403 fiscal_receipts_disabled приходит только боевым организациям, в песочнице чеки работают всегда. Позиция каталога фискальна, только если у неё есть И штрихкод (barcode), И НТИН Нацкаталога (ntin): такая позиция даёт issued с реальными суммами, а позиция без НТИН — failed / item_not_fiscal, ровно как в бою. В POST /receipts добавлено поле simulate — только для sandbox-организаций: {"simulate": {"status": "failed", "error_code": "shift_closed"}} форсирует исход чека, чтобы обкатать обработку ошибок (error_code — shift_closed | item_not_fiscal | receipt_kaspi_error, по умолчанию receipt_kaspi_error; status — issued | failed). На боевой организации simulate возвращает 403 not_sandbox, чек не создаётся. Вебхуки receipt.issued / receipt.failed в песочнице уходят независимо от прод-флага вебхуков — доставку можно проверить через GET /webhook-logs?event=receipt.failed. Изменение аддитивное.
- **2026-07-12** [CHANGED] Базовый URL API изменён на https://api.apipay.kz/api/v1 (партнёрский — https://api.apipay.kz/api/partner). Обновите базовый адрес в своих интеграциях.
- **2026-07-12** [NEW] Новая группа эндпоинтов «Фискальные чеки» (Kaspi OFD) для оплат, НЕ прошедших через Kaspi QR — наличными (payment_type=3) и через POS другого банка (payment_type=5): POST /receipts/preview (синхронное превью строк чека для UI), POST /receipts (асинхронно выбивает чек) и GET /receipts/{id} (статус и реквизиты — fpd, operation_id, link, shift_number). Модель асинхронная: чек создаётся в статусе pending и переходит в issued или failed — итог узнавайте поллингом GET /receipts/{id} либо вебхуком receipt.issued / receipt.failed. Идемпотентность по client_operation_id (уникален на организацию): повтор с тем же ключом не выбивает второй чек (409 duplicate_client_operation_id); повтор после failed разрешён с НОВЫМ ключом. Позиции — из синхронизированного каталога по catalog_item_id, только фискально зарегистрированные (с НТИН), иначе item_not_fiscal. Фича за kill-switch: при отключении отдаётся 403 fiscal_receipts_disabled; включается постепенно. Изменение аддитивное.
- **2026-07-12** [NEW] GET /catalog: добавлен фильтр without_ntin. При without_ntin=true возвращаются только позиции без НТИН (ntin = null), независимо от наличия штрихкода — шире, чем поле ответа ntin_missing (оно требует непустой barcode). Удобно считать «сколько осталось доделать» по meta.total. Компонуется со всеми режимами и фильтрами (statuses[], search и т.д.). Изменение аддитивное.
- **2026-07-11** [NEW] Новый эндпоинт GET /catalog/queue — остаток pending-очереди приёма каталога (POST /catalog) со слим-полями плюс блок queue с ETA в минутах. ETA учитывает общую FIFO-очередь кассира. Плоская пагинация {current_page, data, total, queue}; параметры sort_order (по умолчанию asc), per_page (20–100), page. Rate-limit 600/min на ключ (выделенный catalog-poll).
- **2026-07-11** [NEW] Новый эндпоинт GET /catalog/errors — журнал ошибок приёма каталога (failed-позиции) с обезличенными текстами ошибок. Фильтр по периоду постановки в очередь (created_at); без from — окно последних 7 дней. Параметры from, to, batch_id, sort_order (по умолчанию desc), per_page (20–100), page. Плоская пагинация {current_page, data, total}. Rate-limit 600/min на ключ.
- **2026-07-11** [NEW] Новый эндпоинт GET /catalog/batches/{id} — агрегированный прогресс bulk-батча приёма каталога (totals: total/created/updated/skipped/failed, pending_remaining, poll_url, статус). Скоуп строго по организации ключа: чужой/несуществующий/битый UUID → 404 (non-enumeration). Итог батча также приходит вебхуком catalog.batch_processed. Rate-limit 600/min на ключ.
- **2026-07-11** [CHANGED] POST /catalog: bulk-приём стал идемпотентным — заголовок Idempotency-Key (или body-поле idempotency_key, ≤191): повтор с тем же ключом возвращает существующий батч (HTTP 200) без пересоздания позиций. В ответы добавлен блок batch (агрегат bulk-батча + poll_url; в 202 — только на инжест-пути). В GET /catalog добавлен фильтр ?batch_id= (по last_batch_id, компонуется со всеми режимами). Новый вебхук catalog.batch_processed — один агрегированный итог bulk-заливки вместо лавины per-item (дедуп по batch_id+status; sample_failed до 50 позиций без текста ошибки).
- **2026-07-09** [NEW] Новый эндпоинт GET /catalog/webhook-logs — read-only логи доставок вебхука catalog.item_processed вашей организации (отдельная таблица, ротация 3 дня). Плоская пагинация {current_page, data, total}; фильтры status, catalog_item_id, created_after, sort_order, per_page, page.
- **2026-07-09** [CHANGED] POST /catalog переведён на match-and-merge: совпадение позиции с существующим товаром возвращает его живой id с маркером matched_existing: true (без дублей), маркер name_differs: true — если совпал barcode/НТИН, но имя другое (имя не перезаписано). external_ref — ключ маппинга (UNIQUE per org, не перетирается), повторная заливка идемпотентна. Новый ответ 409 error_code catalog_busy — каталог занят другой операцией, повторите запрос.
- **2026-07-09** [CHANGED] GET /catalog: по умолчанию во всех режимах отдаётся только статус active — прочие статусы запрашивайте явно через statuses[]. Призраки (deleted без kaspi_item_id) не отдаются никогда. Targeted-режим: суммарно ≤200 значений по всем наборам и ≤1000 строк соответствий, превышение → 422 error_code catalog_match_overflow (тихого усечения limit(200) больше нет).
- **2026-07-09** [CHANGED] Схема товара каталога (CatalogItem) дополнена полями: ntin_missing (есть barcode, но нет НТИН → не попадёт в фискальный чек как маркированный; присутствует во всех ответах), matched_existing и name_differs (только в ответе POST /catalog). Все изменения аддитивные.
- **2026-07-07** [NEW] Многоуровневая верификация бизнеса (tiered KYC). Молодая организация проходит короткую анкету «Расскажите о бизнесе» (кабинет, /business-profile). Пока анкета не одобрена, в рабочем режиме действует дневной лимит боевых счетов (окно Asia/Almaty; песочница без ограничений) — при превышении HTTP 429 error_code kyc_daily_limit_reached (meta.limit, meta.reset_at, meta.kyc_status); ⚠️ актуальное поведение — в записи от 26.08.2026: этот лимит равен нулю, то есть до одобрения боевые счета не выставляются вовсе. По итогам проверки организация может быть заблокирована — создание счёта тогда отдаёт HTTP 403 error_code kyc_rejected. Для ещё не одобренных организаций в рабочем режиме адрес webhook должен быть на вашем домене: IP → HTTP 422 webhook_url_requires_domain, туннели (ngrok и подобные) → HTTP 422 webhook_url_tunnel_forbidden (в песочнице правила мягче). Все изменения аддитивные. Зачем это нужно: это разовая проверка, по итогам которой лимиты на приём платежей настраиваются под ваши обороты.
- **2026-07-06** [NEW] Документация /docs выровнена с кодом. Добавлены разделы: Quickstart «первый счёт за 5 минут», Rate-лимиты (полная таблица), Идемпотентность и ретраи, QR-счета: жизненный цикл (событие invoice.qr_scanned), Мультикассир (/connections* с гейтом can_manage_cashiers), Тариф и здоровье аккаунта (GET /tariff, /tariff/plans, /account/health), Bulk-счета (POST /invoices/bulk, до 100, лимит 20/мин), Sandbox (магические номера, simulate-*). Каталог error_code дополнен антифрод-кодами (outstanding_recipient_limit, outstanding_org_limit, trial_daily_limit — 429 + Retry-After). Все изменения аддитивные.
- **2026-07-06** [NEW] Автономное тестирование для ИИ-агентов: sandbox-агент с API-ключом теперь проходит полный цикл создание счёта → изменение статуса → проверка вебхука без человека. (1) POST /api/v1/invoices/{id}/simulate-status расширен: помимо paid/cancelled/expired добавлены значения error (счёт получает error_code: sandbox_simulated_error и опциональный error_message до 255 символов, уходит вебхук invoice.status_changed как при реальной ошибке) и qr_scanned (только для QR-счетов: статус остаётся pending, уходит вебхук invoice.qr_scanned с qr_substate=scanned, повтор → 400 already_scanned). Симуляция работает ТОЛЬКО в песочнице (is_sandbox=true); в рабочем режиме недоступна ни при каких условиях (боевой счёт → 403 not_sandbox). Переход только из pending (иначе 400 invalid_status_transition). Отдельный лимит 60 запросов/мин на ключ. (2) Новые read-only эндпоинты GET /api/v1/webhook-logs и GET /api/v1/webhook-logs/{id} (X-API-Key) — программная верификация доставки вебхуков: фильтры invoice_id, event, status, date_from/date_to, плоская пагинация {current_page, data, total}; поля event, url, response_status, status, request_body, response_body, response_time_ms, created_at; чужой лог → 404. Retry в v1 нет (только в кабинете). Sandbox-вебхуки: 3 попытки, backoff 5с/15с (в проде до 11 попыток ~2ч), успех = любой 2xx. Изменения аддитивные и обратносовместимые.
- **2026-07-06** [NEW] POST /invoices: задокументирован ключ идемпотентности external_order_id_idempotency. Если передать external_order_id_idempotency (до 191 символа), повторный запрос с тем же значением не создаёт дубликат, а возвращает 409 с error `duplicate_idempotency_key` (в теле invoice_id и status существующего счёта). Перевыставить счёт с тем же external_order_id_idempotency можно только когда предыдущий счёт с этим ключом уже в статусе expired, cancelled или error. Это отдельное поле: external_order_id (до 255 символов) остаётся справочным внешним ID заказа (сохраняется в счёте, приходит в вебхуках, участвует в поиске) и ключом идемпотентности не является. Дополнительно в теле POST /invoices задокументирован опциональный kaspi_connection_id (ID кассира) — для организаций с несколькими активными кассами; при отсутствии выбранной primary-кассы и нескольких активных возвращается 422 connection_ambiguous (ранее это было описано только для QR-счетов POST /invoices/qr). Изменения документационные и аддитивные: поведение бэкенда не менялось.
- **2026-07-06** [NEW] В каталог error_code добавлены антифрод-коды защиты пользователей Kaspi от злоупотреблений (каталог расширен до 27 значений): trial_daily_limit (на пробном тарифе лимит 50 счетов/сутки), outstanding_recipient_limit и outstanding_org_limit (слишком много неоплаченных счетов на одного получателя / по организации), recipient_fanout_exceeded (превышен темп рассылки счетов по разным получателям, fan-out) — все четыре приходят синхронно как HTTP 429 с заголовком Retry-After; content_rejected — содержимое счёта отклонено проверкой, HTTP 422. Стройте обработку по error_code. Изменение документационное и аддитивное.
- **2026-07-01** [NEW] Добавлен новый эндпоинт POST /api/v1/catalog/scan — синхронный резолв штрихкода в Нацкаталоге Kaspi. Возвращает список товаров-кандидатов (id, name, ntin, gtin, barcode, unit_id, image_link) + normalized_barcode + scan_result; один штрихкод может дать несколько кандидатов (общий gtin, разные ntin). Пустой data[] означает, что товар не найден в Нацкаталоге — это НЕ ошибка (HTTP 200). Лимиты: 30 запросов/мин и 2000/сутки на API-ключ; circuit-breaker: при троттлинге Kaspi ~90 секунд сразу отдаётся 429. Ошибки сканирования: 400 kaspi_session_expired (нужна переавторизация кассира Kaspi), 429 kaspi_throttled (тело retry_after_seconds, заголовок Retry-After), 503 kaspi_scan_unavailable. Дополнительно при создании товара (POST /catalog) появились опциональные поля ntin, gtin и from_catalog, а при редактировании (PATCH /catalog/{id}) — опциональные ntin и gtin; в ответах GET и POST /catalog у товара добавилось поле gtin (может быть null). Все изменения аддитивные и обратносовместимые. ВАЖНО: при обычном редактировании (PATCH) НЕ передавайте ntin/gtin — пустое значение (null) затрёт идентичность Нацкаталога в Kaspi и его нельзя восстановить синхронизацией.
- **2026-06-24** [CHANGED] Поэлементный возврат (POST /invoices/{id}/refund, return_items[]) теперь принимает на позицию РОВНО одно из двух полей: count (целые штуки, как раньше; сумма = price × count) ЛИБО amount (произвольная сумма по позиции, 0.01 … 9 999 999.99, не больше остатка по позиции). Это позволяет вернуть часть денег по неделимой позиции (count=1, например услуга). Указание обоих полей или ни одного — ошибка 422 (Validation failed) с ключами errors.return_items.{i} (оба/ни одного) и errors.return_items.{i}.amount|count (превышение остатка). Изменение аддитивное и обратносовместимое: старый формат с count работает без изменений. В ответе 201 и в вебхуке invoice.refunded у позиции, возвращённой по amount, refund.items[].count = 0, а деньги — в amount.
- **2026-06-15** [CHANGED] Для QR-счетов (POST /invoices/qr) поле description ограничено 100 символами — это наименование позиции в QR-чеке Kaspi, длина которой ограничена самим Kaspi. При превышении возвращается 422 (Validation failed) с errors.description. У телефонного счёта (POST /invoices) причина и длина свои — см. запись от 2026-08-26 про лимит описания в 60 символов. Рекомендация для QR: краткое описание (номер заказа + имя), без длинных названий.
- **2026-06-14** [NEW] Новое вебхук-событие invoice.qr_scanned: клиент отсканировал QR-счёт и находится на экране оплаты Kaspi. status остаётся pending, payload содержит маркер qr_substate=scanned. Событие аддитивное, шлётся ровно один раз на QR-счёт и транзиентно — после него штатно приходит invoice.status_changed со status paid (оплатил) или cancelled (свернул/закрыл приложение). HMAC-подпись не менялась.
- **2026-06-14** [CHANGED] Изменена модель жизненного цикла QR-счетов (POST /invoices/qr). QR-счета теперь СОСУЩЕСТВУЮТ: создание нового QR на той же кассе больше НЕ отменяет прежние — старый QR остаётся pending и мониторится до своего терминала, вебхук cancelled с текстом «Заменён новым QR-счётом #N» больше НЕ приходит. Два параллельных запроса оба получают 201 + pending (409 superseded остался как defensive-ветка, на практике недостижим). status cancelled по QR теперь означает реальную отмену клиентом (клиент свернул или закрыл приложение Kaspi), а не системную замену. status expired по QR приходит только когда Kaspi отдал терминал (ссылка перестала действовать на стороне Kaspi) — реагируйте на терминальные вебхуки по каждому invoice.id отдельно (возможны два paid), а не по локальному таймеру qr_expires_at. Жизненный цикл QR — минуты, не 24 часа (phone-счета по-прежнему 24 часа). По каждому QR терминальный статус формируется один раз, но доставку вебхука дедуплицируйте у себя по паре (invoice.id, invoice.status) и сверяйтесь запросом GET /invoices/{id}: повторная доставка одного перехода возможна, а при недоступности вашего адреса отправка приостанавливается.
- **2026-06-11** [NEW] Вебхуки: добавлены уведомления о неудачных возвратах (invoice.refunded со status=failed и refund.error_code), об ошибках QR-счетов (invoice.status_changed со status=error и error_code: kaspi_session_invalid / kaspi_error / qr_render_failed — дублирует синхронный 5xx-ответ) и о первом частичном возврате (invoice.status_changed со status=partially_refunded). Изменения аддитивные, HMAC-подпись не менялась.
- **2026-06-11** [NEW] Документация вебхуков актуализирована: добавлены события subscription.created/paused/resumed/cancelled, примеры payload для статусов error/cancelled/failed, разделы «Когда приходит вебхук», «Переходы статусов» (включая легитимные cancelled→paid и error→pending) и «Сценарии реагирования» (что система делает автоматически и что делать интегратору по каждому error_code), описание circuit breaker и ретраев (включая sandbox). Исправлено: даты в вебхуках — UTC; статуса refunded у счёта не существует (полный возврат оставляет paid + is_fully_refunded).
- **2026-06-01** [NEW] GET /catalog: добавлен опциональный query-параметр statuses[] — фильтр списка товаров по статусу (можно передать несколько значений: active, pending, deleting, failed). Фильтрация выполняется на сервере до пагинации, поэтому meta.total и last_page отражают отфильтрованную выборку. Изменение аддитивное: без параметра поведение прежнее (возвращаются все видимые товары). Статус deleted в выдачу не попадает by design.
- **2026-05-29** [CHANGED] Уточнения в документации по итогам сверки с бэкендом: общий лимит Public API — 200 запросов/мин на API-ключ (при 429 — заголовок Retry-After); лимиты 60 запросов/мин и 10 000 запросов/сутки относятся только к POST /clients/check; POST /invoices/qr — отдельный счётчик 60 QR/мин на организацию (на его 429 заголовка Retry-After нет). Лимит тестовых счетов в sandbox — 500 на организацию. В каталоге error_code для каждого кода размечена синхронность доставки: async (приходит в webhook) либо sync (HTTP-ответ с указанным кодом).
- **2026-05-28** [NEW] Унификация ошибок: во все JSON-ответы об ошибках и в webhook-объекты invoice (status=error) и refund (status=failed) добавлено новое поле error_code — стабильный snake_case-код из фиксированного каталога (например client_not_found, network_unavailable, kaspi_error, qr_render_failed, kaspi_session_invalid). Изменение аддитивное и обратно совместимое: поля message и error не изменились. Определяйте тип ошибки по error_code, а не по тексту message. error_code в refund-объекте приходит без error_message; HMAC-подпись webhook не менялась.
- **2026-05-28** [NEW] Webhook invoice.status_changed теперь может содержать два опциональных поля от Kaspi: kaspi_source_type — источник средств клиента (GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт) и kaspi_sale_type — способ приёма счёта (Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR). Обычно приходят при status=paid, но гейтятся по наличию значения, а не строго по статусу: присутствуют, когда Kaspi вернул значение (счёт, уже получивший его, может пробросить поле и в cancelled/expired), и отсутствуют/null иначе — обрабатывайте как nullable. Перечислены известные на сегодня значения; список может расширяться на стороне Kaspi, поэтому обрабатывайте неизвестные значения как «прочее».
- **2026-05-28** [CHANGED] Уточнение по POST /invoices/qr: лимит «один активный QR» считается per-кассир (kaspi_connection_id), а не на всю организацию. Если у организации несколько активных касс, можно показывать разные QR одновременно — на каждую кассу свой. В теле запроса добавлен опциональный kaspi_connection_id — для multi-cashier-организаций (если не указан и >1 активной кассы без primary, вернётся 422 connection_ambiguous). Параллельные запросы на одну и ту же кассу больше не блокируются — race ловится conditional UPDATE; проигравший запрос получает 409 superseded с invoice_id уже отменённого счёта, по этому id придёт webhook со status=cancelled. Дедуплицируйте по (invoice_id + status). В sandbox-режиме scope — per-organization (одна касса). Phone-инвойсы (is_qr_token=false) — TTL 24 часа в Kaspi, не затрагиваются supersede-логикой.
- **2026-05-27** [NEW] Новый эндпоинт POST /api/v1/clients/check — точечная проверка номера на наличие в Kaspi перед созданием счёта или подписки. Возвращает { phone (нормализованный), has_kaspi, client_name }. Нормализует форматы +7/7/8 и убирает пробелы/дефисы. Не предназначен для массового перебора номеров — на стороне сервера работает детекция аномалий, при срабатывании API-ключ деактивируется без предупреждения, организация блокируется до ручной проверки.
- **2026-05-09** [NEW] Новый эндпоинт POST /api/v1/invoices/qr — оплата по QR-коду на экране кассы без номера телефона. В ответе qr_token_url (Kaspi-ссылка) и qr_image_url (PNG 600×600 с логотипом Kaspi). TTL — 5 минут. После оплаты прилетает обычный webhook invoice.status_changed (status: paid). Поддерживаются режимы с каталогом (cart_items, discount_percentage) и без (amount). Для sandbox-организаций добавлен опциональный параметр simulate (paid|cancelled|expired) — создаёт счёт сразу в нужном терминальном статусе с мгновенной отправкой webhook (отдельный вызов /simulate-status больше не нужен, но по-прежнему работает).
- **2026-05-01** [CHANGED] Параметр price в cart_items — переопределение цены позиции (если не указан, берётся selling_price из каталога)
- **2026-04-07** [NEW] Статус processing — счёт создан и ожидает отправки в Kaspi
- **2026-04-07** [NEW] Статус error и поле error_message — описание ошибки при сбое отправки
- **2026-04-07** [NEW] Параметр discount_percentage — скидка на весь чек (1-99%)
- **2026-04-07** [NEW] Параметр bill_immediately — выставить первый счёт подписки сразу
- **2026-04-07** [CHANGED] POST /invoices/{id}/cancel — теперь работает для статусов pending и processing
- **2026-04-07** [NEW] Ошибки Kaspi-сессии: 400 kaspi_session_not_configured, 503 kaspi_session_invalid
- **2026-04-07** [CHANGED] POST /invoices/status/check — обновлён формат ответа: { invoices: [...] }
- **2026-04-02** [CHANGED] Обновлена API спецификация: добавлены новые поля в каталоге (kaspi_item_id, nds_percentage, ntin, synced_at и др.), подписках (billing_period_label, status_label, status_color, paused_at, cancelled_at и др.) и возвратах (organization_id, updated_at), обновлён формат пагинации
- **2026-03-29** [NEW] Добавлен параметр bill_immediately в POST /subscriptions — немедленное выставление первого счёта при создании подписки
- **2026-03-29** [NEW] Добавлено поле created_at в ответы эндпоинтов каталога (GET /catalog, POST /catalog) — дата создания товара в системе в формате ISO 8601
- **2026-03-27** [NEW] Запуск публичной API документации с единым источником правды
