Полная документация ApiPay REST API v2 — Автоматизация Kaspi Pay (Phone Payments)
REST API для приёма платежей по номеру телефона через Kaspi Pay. Чеки через Kaspi ОФД, webhooks, поддержка каталога, подписок и возвратов. Без скрытых комиссий.
Подключаете через ИИ-ассистента?
Начните с пошагового плейбука интеграции — apipay.kz/for-ai:
весь сценарий (подключение кассира, ключи, код, вебхуки, отладка) в одном документе.
Быстрый старт
Песочница доступна сразу! При регистрации создаётся sandbox-организация для тестирования API. Вы можете создавать тестовые счета (is_sandbox=true) до подключения Kaspi Pay. Переключение в production — через личный кабинет.
Для работы с реальными платежами подключите кассира Kaspi: самостоятельно в личном кабинете (Настройки → Авторизация Kaspi, около 1 минуты) или через WhatsApp поддержки — после подключения не входите в приложение Kaspi Pay под номером этого кассира: привязка разорвётся и счета перестанут выставляться до переподключения.
Настройте webhook в личном кабинете для получения уведомлений об оплате
API-ключ — это секрет. Используйте его только на серверной стороне: не размещайте в коде страницы, в мобильном приложении и в публичных репозиториях. Если ключ мог попасть наружу — удалите его в кабинете (Настройки → Подключение) и выпустите новый.
Базовая конфигурация
Параметр
Значение
Base URL
https://api.apipay.kz/api/v1
Аутентификация
Header X-API-Key: ваш_api_ключ
Rate Limit
200 req/min на API-ключ (общий лимит Public API; при превышении 429 + Retry-After)
Content-Type
application/json
Обзор эндпоинтов
#
Метод
Путь
Описание
Счета (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
Счета подписки
Счета (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
Статус сессии кассира
Подписки (Subscriptions)
46
POST
/subscriptions/{subscription}/simulate-invoice
Создать sandbox-счёт подписки
47
POST
/subscriptions/{subscription}/start-simulation
Запустить авто-симуляцию подписки
48
POST
/subscriptions/{subscription}/stop-simulation
Остановить авто-симуляцию подписки
Каталог (Catalog)
49
GET
/catalog/webhook-logs
Логи доставок catalog.item_processed
50
GET
/catalog/queue
Остаток очереди приёма каталога + ETA
51
GET
/catalog/errors
Ошибки приёма каталога
52
GET
/catalog/batches/{id}
Прогресс bulk-батча приёма каталога
76
POST
/catalog/bulk-delete
Массовое удаление позиций каталога
Other
53
POST
/receipts/preview
Превью фискального чека
54
POST
/receipts
Выбить фискальный чек
55
GET
/receipts/{id}
Статус фискального чека
56
GET
/receipts
История фискальных чеков
57
POST
/static-qr
Создать печатный QR под сделку (отложенный счёт)
58
GET
/static-qr
Список печатных QR организации
59
GET
/static-qr/{id}
Получить печатный QR под сделку
60
DELETE
/static-qr/{id}
Отключить печатный QR под сделку
61
POST
/qr-refunds
Старт QR-возврата
62
GET
/qr-refunds/{id}
Статус сессии QR-возврата
63
GET
/qr-refunds/{id}/operations
Возвратные операции клиента
64
GET
/qr-refunds/{id}/operations/{ref}
Детали возвратной операции
65
POST
/qr-refunds/{id}/execute
Выполнить возврат (синхронно)
66
POST
/qr-refunds/{id}/simulate
Симулировать переход QR-возврата (sandbox)
67
GET
/cashbox/summary
Сводка по наличным за день
68
GET
/cashbox/shifts
Список кассовых смен
69
GET
/cashbox/reconciliation
Сверка наших счетов с кассой Kaspi
70
POST
/cashbox/shifts/close
Закрыть смену (async)
71
GET
/cashbox/operations/{id}
Статус кассовой операции (поллинг)
72
GET
/cashbox/shifts/{shift}/report
Ссылка на PDF-отчёт по смене
73
GET
/cashbox/settings
Текущие тумблеры кассы
74
PUT
/cashbox/settings/auto-close
Тумблер автозакрытия смены
75
PUT
/cashbox/settings/auto-withdrawal
Тумблер автоизъятия наличных
Health Check
GET /status
Проверка доступности API. **Без авторизации** (`X-API-Key` не требуется).
Подпадает под общий гостевой лимит `60 запросов/мин на IP`.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
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`.
**Окно `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)
Поле
Тип
Обяз.
Описание
search
string
—
Поиск по описанию, телефону, external_order_id.
status[]
string[]
—
Фильтр по статусам (можно несколько значений).
`partially_refunded` — счёт, по которому уже был частичный возврат: деньги он
принял, поэтому фильтр «покажи оплаченные» без него неполон.
⚠️ Для сверки кассы фильтруйте по обоим статусам сразу
(`status[]=paid&status[]=partially_refunded`) либо берите итоги из
`GET /api/v1/cashbox/reconciliation`.
date_from
string
—
Начало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty).
date_to
string
—
Конец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from.
date_field
string
—
По какому времени резать окно.
`created_at` — момент выставления счёта (по умолчанию).
`paid_at` — момент оплаты; так операции раскладывает по сменам терминал, поэтому
для сверки кассы используйте его. Неоплаченные счета при этом отсеиваются.
sort_by
string
—
Поле сортировки (невалидное значение → created_at).
sort_order
string
—
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
current_page
integer
—
—
data
any[]
—
—
total
integer
—
—
Всего счетов под фильтром (для пагинации).
POST /invoices
Создаёт счёт на оплату по номеру телефона. Обработка **асинхронная**: ответ `201`
приходит со `status: "processing"` (Kaspi ещё не вызван), финальный статус
(`pending`/`error`) — вебхуком `invoice.status_changed` или поллингом
`GET /invoices/{id}`. Не пересоздавайте счёт, пока он в `processing`.
Два режима суммы: без корзины (`amount`) или с корзиной (`cart_items`, сумма
считается сервером). Организация с каталогом может выставить счёт и одной суммой,
без `cart_items`. Обратное неверно: организация без каталога с `cart_items`
получит `422 This organization does not support catalog.`
Для `POST /invoices/qr` и `POST /static-qr` правило другое — там у организации
с каталогом корзина обязательна.
⛔ **Сумма — только целые тенге.** Счёт на номер телефона принимает
исключительно целое число тенге; дробная сумма (`175.74`) отбивается сразу с
`422 amount_must_be_whole_tenge`, и это относится и к итогу корзины. Проверяется
**итог после скидок**: `discount_percentage` считается построчно, поэтому даже при
целых ценах итог может стать дробным (`999 ₸` со скидкой `10 %` → `899.10`) —
округляйте цены позиций или процент скидки. Минимальная сумма — `1`. Нужны тиыны —
выставляйте через `POST /invoices/qr`: там дробные суммы принимаются и оплачиваются.
GET /invoices/{id}
Полный объект счёта с позициями. Доп. лимит **1000/мин**
вместо общего 200/мин — под поллинг 1С без вебхуков.
Читает из БД/кэша, не бьёт в Kaspi (терминальные статусы кэшируются 24ч).
Статуса `refunded` не существует — полный возврат оставляет `paid` +
`is_fully_refunded=true`.
PATCH /invoices/{id}
Правит **только** `internal_comment` — внутреннюю заметку мерчанта («кто это /
что это»). Заметка в Kaspi не передаётся, плательщик её не видит, в чек она не
попадает; на сумму, статус и возвраты не влияет. Другие поля счёта этим методом
не редактируются.
Доступно в **любом** статусе, включая `paid` и `expired`: закрытый счёт всё ещё
можно разметить — в том числе счета, приехавшие синком из истории Kaspi.
`null` или пустая строка стирают заметку. Тело **без ключа** `internal_comment` →
`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 сосуществуют, старый
не мешает.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
invoice
any
—
—
POST /invoices/{id}/refund
Полный или частичный возврат по оплаченному счёту. Без `amount` — полный возврат.
Ответ `201` = возврат принят и поставлен в очередь (`status: pending`). Итог —
вебхук `invoice.refunded` (`completed`/`failed`, причина в `refund.error_code`).
Для счетов с корзиной можно указать `return_items` — на позицию **ровно одно** из
полей `count` (целые штуки) ИЛИ `amount` (произвольная сумма); оба или ни одного → `422`.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
refund
any
—
—
invoice
object
—
—
invoice.id
integer
—
—
invoice.amount
string
—
—
invoice.total_refunded
string
—
—
invoice.available_for_refund
number
—
—
Сумма, доступная для возврата (число, не строка).
invoice.pending_refund_amount
number
—
—
Сумма ожидающих возвратов (число, не строка).
GET /invoices/{id}/refunds
Список возвратов конкретного счёта с агрегатами по счёту. Даты — UTC +00:00.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
invoice
object
—
—
invoice.id
integer
—
—
invoice.amount
string
—
—
invoice.total_refunded
string
—
—
invoice.available_for_refund
number
—
—
invoice.is_fully_refunded
boolean
—
—
refunds
any[]
—
—
total
integer
—
—
POST /invoices/status/check
Возвращает актуальные статусы нескольких счетов организации за один запрос.
`invoice_ids.*` не проверяется на существование (анти-энумерация) — несуществующие
и чужие ID молча не попадут в ответ. Скоуп — организация ключа.
Параметры запроса
Поле
Тип
Обяз.
Описание
invoice_ids
integer[]
Да
ID счетов для проверки. Отправляйте не более 100 ID за запрос, крупные списки разбивайте на части.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
invoices
object[]
—
—
invoices.id
integer
—
—
invoices.status
string
—
—
invoices.kaspi_invoice_id
string
—
да
invoices.amount
string
—
—
invoices.error_message
string
—
да
invoices.updated_at
string
—
—
Возвраты (Refunds)
GET /refunds
Все возвраты организации (по всем счетам) с фильтрацией. Пагинация — **плоская**
`{current_page, data, total}`. Даты в ОТВЕТЕ — UTC `+00:00`.
Окно `date_from`/`date_to` — по времени ОПЕРАЦИИ возврата, в зоне мерчанта
(Asia/Almaty): голая дата = календарные сутки целиком, `Y-m-d H:i` = точная минута.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
status[]
string[]
—
Фильтр по статусам возврата.
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
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
current_page
integer
—
—
data
any[]
—
—
total
integer
—
—
Каталог (Catalog)
GET /catalog/units
Список единиц измерения для товаров. Получите перед созданием товаров, чтобы использовать корректные unit_id.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
object[]
—
—
data.id
integer
—
—
data.name
string
—
—
data.name_kaz
string
—
—
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`. Чтобы получить другие статусы (`pending`/`failed`/`deleting`/
`deleted`) — передайте `statuses[]` явно (в т.ч. в incremental для зеркалирования
удалений: `?updated_after=…&statuses[]=active&statuses[]=deleted`).
**Призраки НЕ отдаются никогда:** строки `status='deleted'` с `kaspi_item_id=null`
(позиции, отклонённые до отправки в Kaspi — их никогда не было в Kaspi) исключаются
во всех режимах, даже при явном `?statuses[]=deleted`. Настоящие удаления
(`deleted` с непустым `kaspi_item_id`) доступны через `?statuses[]=deleted`.
**Лимиты 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)
Поле
Тип
Обяз.
Описание
statuses[]
string[]
—
Фильтр по статусам товара. Не передан → отдаётся только `active` (default) во всех режимах. Призраки (`deleted` + пустой `kaspi_item_id`) не отдаются даже при `deleted`.
ntins[]
string[]
—
Targeted: НТИН'ы (≤200).
barcodes[]
string[]
—
Targeted: штрихкоды (≤200).
ids[]
integer[]
—
Targeted: ID товаров (≤200).
external_refs[]
string[]
—
Targeted: клиентские ссылки external_ref (≤200).
updated_after
string
—
Incremental: только изменённое после даты (вкл. удалённые).
cursor
string
—
Keyset: курсор пагинации (из meta.next_cursor).
search
string
—
Поиск по названию.
barcode
string
—
Точный фильтр по штрихкоду.
first_char
string
—
Фильтр по первой букве названия.
without_ntin
boolean
—
`true` → только позиции без НТИН (`ntin` = null), независимо от наличия штрихкода — шире, чем поле ответа `ntin_missing` (то требует непустой `barcode`). Удобно считать «сколько осталось доделать» по `meta.total`. Компонуется со всеми режимами и фильтрами (`statuses[]`, `search` и т.д.).
batch_id
string
—
Фильтр по UUID батча bulk-приёма (last_batch_id). Компонуется со всеми режимами.
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
links
any
—
—
meta
any
—
—
POST /catalog
Создаёт 1–100 товаров пакетно. Sandbox → `201` (товар сразу активирован);
Production → `202` (товар в статусе `pending`, синхронизация в Kaspi джобом).
`external_ref` — клиентская ссылка (например код 1С) для последующего точечного
чтения (`?external_refs[]=`).
**Валидация per-item lenient.** Каждая позиция валидируется отдельно: валидные
обрабатываются штатно (`data[]`), невалидные попадают в `rejected[]` (см. ниже) и
НЕ роняют весь запрос. Одна битая позиция больше не даёт `422` на весь батч. `422`
остаётся ТОЛЬКО за структурными ошибками ЗАПРОСА: `items` отсутствует / не массив /
пуст / больше лимита (100). Ключ `rejected[]` присутствует в ответе ВСЕГДА (пустой
`[]`, когда все позиции валидны). Если ВСЕ позиции невалидны — ответ всё равно
успешный (`202`/`201`) с пустым `data[]` и полным `rejected[]`.
**Match-and-merge работает и в sandbox** (тот же матчинг и маркеры), но синхронно и
без обращения к Kaspi: matched-товар возвращается сразу активным, переиздание
удалённого по `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`
указывает на удалённый (`deleted`) товар — он автоматически переиздаётся: ТА ЖЕ
строка (тот же `id`) возвращается в `pending` с полями из запроса и заново
отправляется в Kaspi (`matched_existing: true`, `status: pending`). Если
`external_ref` указывает на `failed`-товар — возвращается его текущая строка со
`status: failed` БЕЗ автоматического повтора: исправьте данные и переотправьте
позицию через `PATCH /catalog/{id}` (для строки без `kaspi_item_id` PATCH сам
переотправляет создание) либо кнопкой «Повторить» в кабинете.
Параметры запроса
Поле
Тип
Обяз.
Описание
idempotency_key
string
—
Ключ идемпотентности (эквивалент заголовка Idempotency-Key). Повтор → тот же батч (200). ⚠️ Пространство ключей ОБЩЕЕ с массовым удалением: ключ, уже занятый операцией другого типа, отдаёт `409 idempotency_key_conflict`.
sync_token
string
—
Метка прогона полной синхронизации. Проставляется на КАЖДУЮ упомянутую позицию — включая те, по которым работы не было (совпадение с каталогом Kaspi). Нужна, чтобы потом удалить остаток «всё, чего в этой заливке не было». Заливка из нескольких запросов шлёт один и тот же токен во всех. ⚠️ Повтор с тем же `idempotency_key` токен НЕ обновляет — берите точку отсчёта ДО первого запроса прогона. ⛔ Не заменяйте это фильтром по времени изменения: `updated_at` не двигается у позиций, совпавших с каталогом, и двигается у тронутых синхронизацией — фильтр по времени удалил бы живое и пощадил мёртвое.
run_total
integer
—
Сколько позиций прогон пришлёт ВСЕГО (не в этом запросе). Шлётся вместе с `sync_token` — можно в каждом запросе прогона, значение одинаковое. ⛔ Без него удаление по фильтру недоступно: сверять факт будет не с чем, и `POST /catalog/bulk-delete` ответит `422 catalog_delete_filter_invalid` с `reason: run_not_closed`. ⛔ Объявляется ОДИН раз, в начале прогона: повтор с другим значением → `422 catalog_run_total_conflict`. Начали другой прогон — возьмите новый `sync_token`.
items
object[]
Да
items.name
string
Да
items.selling_price
number
Да
items.unit_id
integer
Да
items.image_id
string
—
ID изображения из upload-image (exists).
items.barcode
string
—
items.ntin
string
—
НТИН кандидата Нацкаталога (из POST /catalog/scan).
items.gtin
string
—
GTIN кандидата Нацкаталога (GS1).
items.external_ref
string
—
Клиентская ссылка (1С).
items.from_catalog
boolean
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
rejected
any[]
—
—
batch
any
—
—
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).
Параметры запроса
Поле
Тип
Обяз.
Описание
image
string
Да
Файл изображения: JPEG или PNG, макс 6144 KB (6 МБ), стороны 64…6000 px, площадь ≤12 Мпикс.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
image_id
string
—
—
PATCH /catalog/{id}
Обновляет товар. Все поля опциональны — применяются только присланные (`filled`).
`external_ref` при обновлении **не принимается**. ⚠️ `ntin`/`gtin` затирают
идентичность Нацкаталога безвозвратно (её нельзя восстановить синком) — отправляйте
только при реальном изменении. Sandbox → `200`, Production → `202`.
⚠️ **`PATCH` по позиции в очереди удаления ОТМЕНЯЕТ удаление** — запрос означает
«позиция мне нужна» и обрабатывается так же, как повторная заливка. Исключение —
узкое окно, когда снятие уже отправлено в Kaspi: тогда приходит
`409 catalog_delete_in_progress`, состояние позиции при этом не меняется.
Повторите через несколько секунд; если попытка снятия не завершилась, окно
закрывается само не позже чем через две минуты — дольше этого кода не бывает.
⛔ **Если позиция к этому моменту уже снята, `PATCH` по ней вернёт `404`:** выборка
идёт только по живым строкам, и снятая позиция для неё не существует. Заведите её
заново обычным `POST /catalog`: по `external_ref` вернётся та же строка (тот же
`id`); у позиции без `external_ref` в каталоге появится новая строка с новым `id`,
а если её штрихкод совпадает с другим живым товаром — присланная позиция сольётся
с ним. Пересоздание есть только у `POST /catalog`, у `PATCH` его нет.
Параметры запроса
Поле
Тип
Обяз.
Описание
name
string
—
selling_price
number
—
unit_id
integer
—
image_id
string
—
Новый ID изображения (exists).
is_image_deleted
boolean
—
true — удалить изображение.
barcode
string
—
ntin
string
—
⚠️ Затирает идентичность Нацкаталога — только при реальном изменении.
gtin
string
—
⚠️ То же предупреждение, что и для ntin.
sync_token
string
—
Та же метка прогона, что у POST /catalog. Нужна, если позицию вы ведёте ТОЛЬКО через PATCH: без неё она будет считаться неупомянутой в прогоне и попадёт под удаление остатка. Проставляется независимо от того, менялись ли поля.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
catalog_item_id
integer
—
—
DELETE /catalog/{id}
Sandbox → `200` (hard delete). Production → `202`: статус позиции становится
`deleting`, а в Kaspi она снимается фоновой обработкой.
⚠️ **Ответ приходит раньше, чем товар исчезает из кассы.** Обычно позиция снимается
за считаные секунды, но если параллельно идёт массовая операция с каталогом, очередь
загружена и снятие займёт дольше. Проверяйте результат чтением `GET /catalog?statuses[]=deleted`, а не сразу
после ответа.
⚠️ Позиция остаётся в `deleting` и после временного сбоя — сервис вернётся к ней
сам, повторный запрос слать не нужно.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
catalog_item_id
integer
—
—
Подписки (Subscriptions)
GET /subscriptions
Подписки организации. Пагинация — **meta-обёртка** `{data, links, meta}`
(`meta.current_page`/`total`/`per_page`/`last_page`/`from`/`to`). Фильтры — точное
совпадение (не LIKE). Сортировка фиксированная: `created_at DESC`. Даты — UTC `+00:00`.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
status
string
—
Точный фильтр по статусу.
external_subscriber_id
string
—
phone_number
string
—
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
links
any
—
—
meta
any
—
—
POST /subscriptions
Создаёт рекуррентную подписку. Правила суммы зависят от организации:
**без каталога** — `amount` обязателен (100–1 000 000), `cart_items` запрещён;
**с каталогом** — `cart_items` обязателен (сумма считается сервером), `amount` игнорируется.
`bill_immediately=true` — первый счёт сразу; иначе по расписанию в `next_billing_at`.
⛔ **Сумма списания — только целые тенге.** Списание выставляется счётом на номер
телефона, поэтому дробная сумма (и голая `amount`, и итог `cart_items` после скидок)
уходит в статус `error` с `error_code: amount_must_be_whole_tenge` — вебхуком
`invoice.status_changed`, и так при КАЖДОМ списании. Проверьте суммы действующих
подписок и цены каталожных позиций, из которых считается итог.
⛔ **Позиция корзины в статусе `deleting` отбивается `422`** — здесь и в
`PUT /subscriptions/{id}`; текст в `errors["cart_items.N.catalog_item_id"]`.
⚠️ На очередном СПИСАНИИ этого гейта нет: если позиция уходит в снятие у уже
работающей подписки, списание не проваливается, а откладывается до следующей
попытки списания — счётчик неудач не растёт и подписка не уходит в grace period
из-за временного состояния. Верните позицию обычным `POST /catalog`.
⚠️ **Отложенное списание ничем не сигнализируется:** счёт за период не создаётся,
вебхука нет, `next_billing_at` не двигается. Списание пройдёт автоматически первой
же попыткой после того, как позиция вернётся в продажу — если деньги нужны раньше,
верните позицию сами.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
subscription
any
—
—
GET /subscriptions/{id}
Подписка со статистикой (`stats`) и последним платежом (`last_payment`).
PUT /subscriptions/{id}
Все поля опциональны. `cart_items` на не-каталог организации → `422`. При передаче
`cart_items` сумма пересчитывается.
⛔ Позиция корзины в статусе `deleting` отбивается `422` так же, как при создании —
текст в `errors["cart_items.N.catalog_item_id"]`.
Возобновляет paused-подписку (next_billing_at пересчитывается от текущего момента, пропущенные периоды не доначисляются).
POST /subscriptions/{id}/cancel
Отменяет active/paused-подписку безвозвратно (реактивации нет — создавайте новую).
GET /subscriptions/{id}/invoices
Счета, созданные подпиской. **Нестандартный конверт** `{data, meta}` — `meta`
содержит `current_page`/`total`/`per_page` (без `last_page`). Даты — UTC `+00:00`.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
meta
object
—
—
meta.current_page
integer
—
—
meta.total
integer
—
—
meta.per_page
integer
—
—
Webhooks
Webhooks настраиваются через личный кабинет ApiPay.kz (Настройки > Подключение). При создании webhook вы получите secret для верификации подписи (HMAC-SHA256).
События
invoice.status_changed
invoice.qr_scanned
invoice.refunded
receipt.issued
receipt.failed
qr_refund.identified
qr_refund.completed
qr_refund.expired
subscription.payment_succeeded
subscription.payment_failed
subscription.grace_period_started
subscription.expired
subscription.created
subscription.paused
subscription.resumed
subscription.cancelled
cashbox.shift_closed
cashbox.shift_close_failed
webhook.test
Когда приходит вебхук
Этот раздел перечисляет события, при которых 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. Равноправно поллингу GET /receipts/{id}. Гейт вебхуков receipt.* отдельный (по умолчанию выключен).
receipt.failed
—
Не удалось выбить фискальный чек (POST /receipts). Причина — receipt.error_code (shift_closed, item_not_fiscal, rfo_missing, receipt_kaspi_error, receipt_dispatch_error). Фискальный документ НЕ создан — повторите с НОВЫМ client_operation_id.
subscription.created
—
Подписка создана через POST /subscriptions. Счета по подписке выставляет система автоматически: первый счёт будет выставлен в next_billing_at (или сразу при bill_immediately). По каждому счёту приходят обычные invoice-вебхуки.
subscription.payment_succeeded
—
Очередной счёт подписки оплачен. failed_attempts сброшен, льготный период (если был) снят.
subscription.payment_failed
—
Счёт подписки истёк или отменён (reason). Пока attempt_number < max_retry_attempts (по умолчанию 3) система САМА перевыставит счёт с интервалом retry_interval_hours (по умолчанию 24 ч) — ничего пересоздавать не нужно, просто уведомите клиента (attempt_number, reason). Счёт со статусом error провалом НЕ считается (это событие не придёт) — отслеживайте invoice-вебхук error.
subscription.grace_period_started
—
Все попытки исчерпаны; подписка ещё активна grace_period_days (по умолчанию 3) дней. Любая успешная оплата снимает льготный период.
subscription.expired
—
Льготный период истёк — биллинг остановлен навсегда, реактивации нет. Для возобновления создайте новую подписку.
subscription.paused
—
Подписка приостановлена (POST /subscriptions/{id}/pause). Счета не выставляются.
subscription.resumed
—
Подписка возобновлена; next_billing_at пересчитан от момента возобновления, пропущенные периоды не доначисляются.
subscription.cancelled
—
Подписка отменена безвозвратно (next_billing_at сохраняет последнее значение, счета не выставляются).
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.
Ваш внешний идентификатор заказа, переданный при создании счёта.
invoice.amount
string
—
Сумма счёта.
invoice.subtotal
string
да
Сумма до применения скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе).
invoice.discount_sum
string
да
Сумма скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе).
invoice.discount_percentage
string
да
Процент скидки. Только для счетов с корзиной/скидкой.
invoice.status
string
—
Статус счёта: pending / paid / cancelled / expired / error / partially_refunded. Статуса refunded не существует — полный возврат оставляет paid + is_fully_refunded=true.
invoice.kaspi_invoice_id
string
да
ID счёта в Kaspi. Появляется уже при pending (когда счёт создан в Kaspi); null — пока счёт не дошёл до Kaspi.
invoice.client_phone
string
—
Номер телефона клиента.
invoice.kaspi_source_type
string
да
Источник средств клиента: GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее».
invoice.kaspi_sale_type
string
да
Способ приёма счёта: Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее».
invoice.paid_at
string
да
Время оплаты счёта (ISO 8601). Поле отсутствует во всех статусах, кроме paid (а не null до оплаты).
invoice.error_message
string
да
Человекочитаемая причина. При status=error присутствует всегда; при status=cancelled — только если заполнена (обычно отсутствует при отмене клиентом или через API). В статусах paid/pending/expired поле отсутствует.
invoice.error_code
string
да
Стабильный snake_case-код из каталога (раздел "Коды ошибок"). Присутствует только если не null и только при status=error/cancelled. Стройте switch-логику по нему, а не по тексту.
invoice.cancelled_at
string
да
Время перехода в cancelled (ISO 8601). Присутствует только при соответствующем статусе.
invoice.expired_at
string
да
Время перехода в expired (ISO 8601). Присутствует только при соответствующем статусе.
invoice.errored_at
string
да
Время перехода в error (ISO 8601). Присутствует только при соответствующем статусе.
pending / processing / completed / failed. Вебхук приходит на completed И на failed.
refund.kaspi_refund_id
string
да
ID возврата в Kaspi; null при неудаче.
refund.reason
string
да
Причина возврата.
refund.created_at
string
—
Время создания возврата (ISO 8601).
refund.error_code
string
да
Только при status=failed. Например refund_window_expired — истёк срок возврата (~14 дней). Поля error_message в вебхуке нет by design — текст смотрите в GET /invoices/{id}/refunds или резолвите код по каталогу.
refund.items
array
да
Позиции возврата (только для позиционных возвратов): catalog_item_id, name, price, count, amount.
invoice.id
integer
—
Внутренний ID счёта в ApiPay.
invoice.external_order_id
string
да
Ваш внешний идентификатор заказа.
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).
Ваш ключ идемпотентности, переданный при выбивании чека.
receipt.payment_type
integer
—
Тип оплаты: 3 — наличные, 5 — POS другого банка.
receipt.status
string
—
Статус чека — failed.
receipt.fpd
string
да
При failed всегда null — фискальный документ не создан.
receipt.operation_id
string
да
При failed всегда null.
receipt.link
string
да
При failed всегда null.
receipt.shift_number
integer
да
При failed обычно null.
receipt.total_price
string
да
Сумма чека.
receipt.error_code
string
да
Код причины: shift_closed (закрыта смена), item_not_fiscal (позиция без НТИН), rfo_missing, receipt_kaspi_error, receipt_dispatch_error. Стройте switch по нему, не по тексту.
ID кассовой операции — тот же, что вернул ответ 202 и по которому идёт поллинг GET /cashbox/operations/{id}. Дедуплицируйте по паре (event, operation.id).
ID кассовой операции. Дедуплицируйте по паре (event, operation.id).
operation.operation_type
string
—
Тип операции. Сейчас всегда close_shift.
operation.status
string
—
Терминальный статус операции — failed.
operation.shift_number
integer
да
Номер смены, которую пытались закрыть.
operation.error_code
string
да
Причина отказа — слаг вида cashbox_*. Повторяйте закрытие только новым client_operation_id: прежний ключ после отказа не освобождается.
timestamp
string
—
Время события в UTC.
Переходы статусов
Гарантия: ровно один вебхук на реальный переход статуса. Дубли подряд одного статуса, технические 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}.
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
Временный сбой сессии кассира
Автоматически инвалидировала сессию и ретраила
Создайте новый счёт позже; если повторяется — переподключите кассира в ЛК
Пока счёт в 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-вебхук (реконсиляция). Следуйте последнему статусу.
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 часа)
Timeout: 5 секунд на ответ (плюс до 3 секунд на установление соединения)
HTTP 2xx = success
Sandbox: В sandbox-режиме invoice-вебхуки доставляются всего за 3 попытки (интервалы 5с, 15с). Вебхуки refund и subscription всегда используют полные 11 попыток — sandbox-сокращения для них нет.
3xx/4xx: Ретраится только HTTP ≥500, ровно 429 и сетевые ошибки. Ответы 3xx и 4xx (кроме 429) НЕ ретраятся — попытка сразу фиксируется как доставленная. Повторить такую доставку можно только вручную: ЛК → Webhook-логи → Retry (доступно для записей со status=failed, cooldown между ручными повторами — 10 секунд).
Дедупликация: Дедупликация на стороне клиента обязательна: ретрай после частичной доставки двум получателям пере-отправляет вебхук всем. Ключи дедупликации: (invoice.id, invoice.status) для invoice-событий, (refund.id, refund.status) для возвратов, (event, subscription.id, invoice_id) для событий подписки. Отвечайте 200 OK быстро (≤5 секунд), обрабатывайте асинхронно.
Наблюдаемость подписок: События subscription.* не пишутся в Webhook-логи ЛК: ручного retry и circuit breaker для них нет — сверяйте состояние подписки через GET /subscriptions/{id} и GET /subscriptions/{id}/invoices.
UTC: Все даты в вебхуках — ISO 8601 в UTC (+00:00).
Circuit breaker
Если ваш endpoint стабильно недоступен, отправка на ключ приостанавливается: 5 подряд неудач → пауза 5 минут, 10 → 30 минут, 20 → 2 часа, 50 → полное отключение до ручного вмешательства. Вебхуки за время паузы НЕ доотправляются — сверяйте состояние через GET-методы. Любая успешная доставка (или успешный тест-вебхук из ЛК) сбрасывает счётчик. Статус виден в списке API-ключей.
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
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'))
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
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
# Шаг 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
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
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
# Сначала список смен: сверка читает смену из уже полученных данных.
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
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
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
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
// 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
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
# Только наличные: оплаты по счетам 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
// Только для 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
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
# Только для 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
// Убедиться, что по счёту 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
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
# Убедиться, что по счёту 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
// Проверить, что позиция каталога 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
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
# Проверить, что позиция каталога 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 + батч)
import requests
# Повтор с тем же Idempotency-Key вернёт существующий батч (200) без пересоздания.
res = requests.post(
'https://api.apipay.kz/api/v1/catalog',
headers={
'X-API-Key': 'YOUR_API_KEY',
'Idempotency-Key': 'import-2026-07-11-0001',
},
json={
'items': [
{'name': 'Фильтр масляный', 'selling_price': 1800, 'unit_id': 1, 'external_ref': '1C-000123'}
]
},
)
batch = res.json().get('batch') or {}
print('Batch:', batch.get('batch_id'), batch.get('poll_url'))
cURL
# Повтор с тем же Idempotency-Key вернёт СУЩЕСТВУЮЩИЙ батч (HTTP 200) без пересоздания.
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-07-11-0001" \
-d '{
"items": [
{ "name": "Фильтр масляный", "selling_price": 1800, "unit_id": 1, "external_ref": "1C-000123" }
]
}'
# В ответе (202/200) — блок batch с batch_id и poll_url.
# Прогресс: GET https://api.apipay.kz/api/v1/catalog/batches/8f14e45f-ceea-467e-9a2c-0000000000aa
# Фильтр списка по батчу: GET https://api.apipay.kz/api/v1/catalog?batch_id=8f14e45f-ceea-467e-9a2c-0000000000aa
Остаток очереди приёма каталога + ETA
JavaScript / Node.js
// Остаток 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
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
# Остаток 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
// Failed-позиции по конкретному батчу (обезличенные тексты ошибок).
const res = await fetch(
'https://api.apipay.kz/api/v1/catalog/errors?batch_id=8f14e45f-ceea-467e-9a2c-0000000000aa&per_page=100',
{ headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
data.forEach((e) => console.log(e.external_ref, e.error_code, e.error_message))
Python
import requests
# Failed-позиции по конкретному батчу (обезличенные тексты ошибок).
res = requests.get(
'https://api.apipay.kz/api/v1/catalog/errors',
headers={'X-API-Key': 'YOUR_API_KEY'},
params={'batch_id': '8f14e45f-ceea-467e-9a2c-0000000000aa', 'per_page': 100},
)
for e in res.json()['data']:
print(e['external_ref'], e['error_code'], e['error_message'])
cURL
# Failed-позиции по конкретному батчу (обезличенные тексты ошибок).
curl "https://api.apipay.kz/api/v1/catalog/errors?batch_id=8f14e45f-ceea-467e-9a2c-0000000000aa&per_page=100" \
-H "X-API-Key: YOUR_API_KEY"
# Без batch_id и from — окно последних 7 дней по created_at.
// Чеки за календарный день мерчанта (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
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
# Чеки за календарный день мерчанта (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 }.
# Чтение истории не гейтится kill-switch'ем — список доступен всегда.
Коды ошибок
Ошибки приходят в нескольких формах — не путайте их между собой:
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-статусы (общие для всех эндпоинтов)
Код
Описание
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 — ошибка валидации полей; детали в объекте errors
429
Too Many Requests — превышен общий лимит Public API (200 запросов/мин на API-ключ); смотрите заголовок Retry-After. Отдельно: POST /clients/check ограничен 60 запросами/мин и 10 000 запросами/сутки на API-ключ, а POST /invoices/qr — 60 QR/мин на организацию (на этом 429 заголовка Retry-After нет)
500
Server Error — внутренняя ошибка сервера
502
Bad Gateway — ошибка на стороне Kaspi API
503
Service Unavailable — сессия Kaspi недействительна или истекла
Поле «error» при создании счёта (POST /invoices, /invoices/qr)
Код
Описание
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)
Код
Описание
qr_rate_limit (429)
Слишком много QR-запросов для организации (лимит 60/мин) — подождите минуту. Заголовок Retry-After на этом 429 не возвращается (в отличие от общего лимита Public API 200/мин)
qr_render_failed (500)
Не удалось сформировать изображение QR-кода — повторите запрос позже
kaspi_error (502)
Kaspi API вернул ошибку при создании QR-токена — повторите позже
Поле «error» при отмене и возврате
Код
Описание
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» при работе с подписками
Код
Описание
sandbox_subscription_limit (400)
Достигнут лимит тестовых подписок (10 на организацию) — очистите песочницу
Organization not verified (403)
Подписки в рабочем режиме доступны только верифицированной организации
Асинхронные ошибки Kaspi: status=error / поле error_message (HTTP-кода нет)
Код
Описание
status=error
Счёт создан (201, статус processing), но Kaspi не смог его обработать — статус сменился на error. Причина текстом в поле error_message (GET /invoices/{id}). У Kaspi нет фиксированных кодов — текст приходит как есть
error_message: номер не в Kaspi
«Этот номер телефона не зарегистрирован в Kaspi. Укажите номер с установленным приложением Kaspi.» — у клиента нет приложения Kaspi; попросите другой номер
error_message: сбой Kaspi
«Ошибка обработки платежа. Обратитесь в поддержку» или «Не удалось обработать счёт после нескольких попыток» — временный сбой Kaspi; повторите создание счёта позже
Поле error_code — стабильный машинный код (новое, рекомендуется)
Код
Описание
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: 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
Не удалось отправить код 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
error_code: content_rejected
Антифрод: содержимое счёта (например текст описания) отклонено проверкой. Исправьте текст и повторите. Доставка: 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
Зарезервирован — сейчас не используется бэкендом. Доставка: —
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)
Код
Описание
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 символов. Обрежьте значение или передайте позицию без штрихкода
catalog_run_total_conflict (422)
Для этого sync_token уже заявлен другой run_total. Размер прогона объявляется один раз, в начале заливки. Начали другой прогон — возьмите новый sync_token
Не задан ни один способ выбора позиций либо задано несколько сразу. Передайте ровно один: ids[], external_refs[] или filter.sync_token_not. Этот же код приходит, когда в режиме фильтра не передан expected_count — узнайте его через dry_run
catalog_match_overflow (422)
В списке слишком много значений — за один запрос принимается не более 200 ids или external_refs. Разбейте выборку на части
catalog_delete_filter_invalid (422)
Этим sync_token не отмечена ни одна позиция — как правило, опечатка в метке. Проверка намеренная: иначе под удаление попал бы весь каталог
catalog_bulk_delete_mismatch (409)
Переданный expected_count разошёлся с фактом: каталог изменился между разведкой и командой. В теле придёт actual_count — повторите dry_run и убедитесь, что удаляете то, что хотели. Проверяется только в режиме фильтра
catalog_multi_tradepoint (409)
У организации несколько торговых точек — массовое удаление для неё закрыто. Обратитесь в поддержку. Тот же код возможен у DELETE /catalog/{id}
catalog_run_in_progress (409)
Заливка этим sync_token ещё идёт: остаток прогона пока не прислан, и удалять его рано. Дождитесь окончания заливки и повторите. В теле приходит reason со значением run_in_progress
catalog_busy (409)
Каталог занят другой операцией — повторите запрос через несколько секунд
idempotency_key_conflict (409)
Переданный Idempotency-Key уже занят операцией другого типа. Возьмите новый ключ. Повтор ТОЙ ЖЕ операции с тем же ключом конфликтом не считается — он вернёт существующий батч и ничего не удалит повторно
request_rate_limited (429)
Превышен лимит эндпоинта — 10 запросов в минуту. Разведка (dry_run) расходует тот же лимит. Пауза — в поле retry_after_seconds и в заголовке Retry-After
Тариф: индивидуальные условия
Код
Описание
custom_tariff_locked (409)
У мерчанта индивидуальные условия тарифа: смена тарифа не самообслуживаемая и оформляется поддержкой. Продление ТОГО ЖЕ тарифа не блокируется — это оплата, а не смена условий. Определяйте состояние заранее: is_custom в GET /api/v1/tariff и can_change_tier в каталогах планов; повтор запроса не поможет
Каталог: паритет has_catalog ↔ cart_items
Код
Описание
error_code: catalog_requires_cart_items
У организации включён каталог товаров: счёт должен нести состав покупки. Передайте cart_items[] — идентификаторы позиций берутся из GET /catalog, цена строки при необходимости переопределяется полем price. Приходит на POST /invoices/qr и POST /static-qr. Создание подписки (POST /subscriptions) корзину тоже требует, но отвечает обычной ошибкой валидации по полю cart_items, без этого кода. Доставка: sync HTTP 422 (без ключа errors)
error_code: catalog_not_supported
У организации каталог товаров выключен, а в запросе передана корзина. Уберите cart_items[] и передайте amount. Если каталог вам нужен — он включается вместе с Kaspi Кассой (ОФД), напишите нам. Доставка: sync HTTP 422 (без ключа errors), а в POST /invoices/bulk — per-item в разборе позиций
Организация этого кассира уже есть в ApiPay и по итогам подтверждения закреплена за вашим аккаунтом — операция удалась. В теле рядом с error и message приходят organization_id и organization_name: кассира подключайте уже в этой организации, список организаций перечитайте из GET /users/me. Повторять OTP на прежнем подключении бессмысленно
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)
Подключение кассира временно недоступно — идут технические работы. Повторите позже, данные подключения не пострадали
KYC: верификация бизнеса и webhook-домен (новое)
Код
Описание
error_code: kyc_daily_limit_reached
Молодая организация: пока анкета о бизнесе не одобрена, доступен 1 реальный счёт в сутки (окно Asia/Almaty; счета в песочнице не считаются). Чтобы снять лимит — заполните короткую анкету «Расскажите о бизнесе» в кабинете (/business-profile), одобрение обычно за 1 рабочий день. Не повторяйте запрос до сброса. Доставка: 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)
Код
Описание
error_code: fiscal_receipts_disabled (403)
Выбивание чеков отключено для боевых организаций (фича включается постепенно). В песочнице чеки работают всегда — обкатайте интеграцию там. Не повторяемая. Доставка: sync HTTP 403
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
В чеке есть позиция, не зарегистрированная фискально: у товара должны быть и штрихкод (barcode), и НТИН Нацкаталога (ntin). Дозаполните НТИН в каталоге (PATCH /catalog/{id}) — позиция станет фискальной. Позиции без НТИН удобно найти через GET /catalog?without_ntin=true. Правило одинаково в бою и в песочнице. Доставка: async, status=failed
error_code: rfo_missing
У кассира не настроен фискальный регистратор (РФО) — Kaspi не может зарегистрировать чек. Проверьте настройки кассы в Kaspi Pos. Доставка: 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
Чек есть только у оплаченного или частично возвращённого счёта. Проверьте 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 (клиент подтверждает сканированием)
Код
Описание
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 живёт минуты (expires_at), и отдельно ограничено время на выбор операции после подтверждения покупателем. Возврат не сделан — начните новую сессию. Не повторяемая.
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}. Сессия жива, можно повторить с корректной суммой.
partial_refund_requires_return_items (422)
Эту покупку Kaspi возвращает только по позициям: пришлите items вместо amount. Напоминание: amount и items взаимоисключимы.
not_found (404)
Сессия не найдена или принадлежит другой организации — код ответа одинаков в обоих случаях (не оракул чужих сессий). Начните новую сессию.
not_sandbox (403)
Поле simulate или POST /qr-refunds/{id}/simulate прислан боевой организацией — форсировать шаги покупателя можно только в песочнице.
Касса: смены, наличные, сверка (/cashbox/*)
Код
Описание
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 нужной точки, а состояние организации пересверяется переподключением кассира или кнопкой «Обновить информацию об организации» в настройках кабинета. ⚠️ Это действие может включить каталог товаров: после него 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)
Текущее значение на кассе проверить не удалось — переключение не выполнено. Повторите позже.
Стройте логику по error_code — поле error дублирует его для обратной совместимости. Текст message не парсите: он может локализоваться и меняться. У ошибок валидации error_code нет — их отличает пара message + errors.
Changelog
2026-08-16 [changed] Персональная ссылка мерчанту сменила адрес: вместо /kyc/{token} она ведёт на /invite/{token}. Выдавайте клиенту ссылку из ответа как есть и не собирайте адрес у себя из токена — форма ответа не изменилась: invite_url, scope, expires_at. Ссылка перестала быть только анкетной: тем же механизмом мерчанту выдаётся ссылка на подключение кассира Kaspi — он открывает её когда удобно и подключает кассира сам, аккаунт в ApiPay ему не нужен. Код из SMS по-прежнему приходит на телефон кассира: ссылка его не заменяет и сама по себе кассира не подключает. Кассирская ссылка живёт заметно меньше анкетной и гасится сразу после успешного подключения — выдавайте её под конкретный разговор с клиентом, а не про запас. Организацию, где владельцем остаётся сам мерчант, выдача по партнёрскому ключу (POST /api/partner/organizations/{organization}/kyc/invite) не видит: она работает только с организациями, которые вы завели сами. Для таких клиентов ссылка выдаётся из партнёрского кабинета, где у выдачи есть параметр scope со значениями kyc и cashier.
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. То же поведение у кабинетного POST /api/partner/managed-organizations. Ключ идемпотентности здесь — тройка из партнёра, 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] Кабинет партнёра: tariff_billing_mode и tariff_limits в GET /api/partner/me. Это read-only витрина вашей тарифной политики: режим выдачи (payment — вы платите за мерчанта по прайсу, assignment — раздаёте назначением) и сетка по всем тарифам каталога в виде списка объектов с полями tier, daily_limit, label и source, где source принимает значения partner_grid или config. Оба поля аддитивны и отдаются только operating-партнёру; у referral они равны null, а форма ответа «не партнёр» не изменилась. Записи нет: и режим, и сетку меняет только ApiPay.
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-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-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-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 [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-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; org_claim_conflict продолжает существовать на другом эндпоинте, POST /partner/claim-requests, там ничего не менялось. Новых полей, роутов и вебхук-событий нет.
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] Песочница фискальных чеков теперь зеркалит рабочий режим и доступна независимо от постепенного включения фичи в бою: kill-switch (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 [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). Пока анкета не одобрена, в рабочем режиме доступен 1 реальный счёт в сутки (окно Asia/Almaty; песочница без ограничений) — при превышении HTTP 429 error_code kyc_daily_limit_reached (meta.reset_at, meta.kyc_status). По итогам проверки организация может быть заблокирована — создание счёта тогда отдаёт 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) ограничение не распространяется — там description по-прежнему до 500 символов. Рекомендация для 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 в итоге придёт ровно один терминальный вебхук даже при рестарте воркера.
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-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 документации с единым источником правды