ApiPay API v2 Documentation — ApiPay.kz

Полная документация 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 — через личный кабинет.
  1. Войдите в личный кабинет apipay.kz/login через WhatsApp
  2. Получите API ключ в Настройки → Подключение
  3. Создавайте счета: POST /api/v1/invoices
  4. Для работы с реальными платежами подключите кассира Kaspi: самостоятельно в личном кабинете (Настройки → Авторизация Kaspi, около 1 минуты) или через WhatsApp поддержки — после подключения не входите в приложение Kaspi Pay под номером этого кассира: привязка разорвётся и счета перестанут выставляться до переподключения.
  5. Настройте webhook в личном кабинете для получения уведомлений об оплате
API-ключ — это секрет. Используйте его только на серверной стороне: не размещайте в коде страницы, в мобильном приложении и в публичных репозиториях. Если ключ мог попасть наружу — удалите его в кабинете (Настройки → Подключение) и выпустите новый.

Базовая конфигурация

ПараметрЗначение
Base URLhttps://api.apipay.kz/api/v1
АутентификацияHeader X-API-Key: ваш_api_ключ
Rate Limit200 req/min на API-ключ (общий лимит Public API; при превышении 429 + Retry-After)
Content-Typeapplication/json

Обзор эндпоинтов

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

Health Check

GET /status

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

Поля ответа

ПолеТипОбяз.NullableОписание
statusstring
timestampstringТекущее время сервера, 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)

ПолеТипОбяз.Описание
status[]string[]Фильтр по статусам (можно несколько значений). `partially_refunded` — счёт, по которому уже был частичный возврат: деньги он принял, поэтому фильтр «покажи оплаченные» без него неполон. ⚠️ Для сверки кассы фильтруйте по обоим статусам сразу (`status[]=paid&status[]=partially_refunded`) либо берите итоги из `GET /api/v1/cashbox/reconciliation`.
date_fromstringНачало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty).
date_tostringКонец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from.
date_fieldstringПо какому времени резать окно. `created_at` — момент выставления счёта (по умолчанию). `paid_at` — момент оплаты; так операции раскладывает по сменам терминал, поэтому для сверки кассы используйте его. Неоплаченные счета при этом отсеиваются.
sort_bystringПоле сортировки (невалидное значение → created_at).
sort_orderstring
per_pageinteger
pageinteger

Поля ответа

ПолеТипОбяз.NullableОписание
current_pageinteger
dataany[]
totalintegerВсего счетов под фильтром (для пагинации).

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Описание
messagestring
invoiceany

POST /invoices/{id}/refund

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

Поля ответа

ПолеТипОбяз.NullableОписание
messagestring
refundany
invoiceobject
invoice.idinteger
invoice.amountstring
invoice.total_refundedstring
invoice.available_for_refundnumberСумма, доступная для возврата (число, не строка).
invoice.pending_refund_amountnumberСумма ожидающих возвратов (число, не строка).

GET /invoices/{id}/refunds

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

Поля ответа

ПолеТипОбяз.NullableОписание
invoiceobject
invoice.idinteger
invoice.amountstring
invoice.total_refundedstring
invoice.available_for_refundnumber
invoice.is_fully_refundedboolean
refundsany[]
totalinteger

POST /invoices/status/check

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

Параметры запроса

ПолеТипОбяз.Описание
invoice_idsinteger[]ДаID счетов для проверки. Отправляйте не более 100 ID за запрос, крупные списки разбивайте на части.

Поля ответа

ПолеТипОбяз.NullableОписание
invoicesobject[]
invoices.idinteger
invoices.statusstring
invoices.kaspi_invoice_idstringда
invoices.amountstring
invoices.error_messagestringда
invoices.updated_atstring

Возвраты (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_idintegerФильтр по ID счёта (должен существовать).
date_fromstringНачало окна включительно — `Y-m-d` или `Y-m-d H[:ii[:ss]]` (зона Almaty).
date_tostringКонец окна включительно, тот же формат. Голая дата = конец суток, время = ровно этот момент. Должен быть >= date_from.
per_pageinteger
pageinteger

Поля ответа

ПолеТипОбяз.NullableОписание
current_pageinteger
dataany[]
totalinteger

Каталог (Catalog)

GET /catalog/units

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

Поля ответа

ПолеТипОбяз.NullableОписание
dataobject[]
data.idinteger
data.namestring
data.name_kazstring

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_afterstringIncremental: только изменённое после даты (вкл. удалённые).
cursorstringKeyset: курсор пагинации (из meta.next_cursor).
barcodestringТочный фильтр по штрихкоду.
first_charstringФильтр по первой букве названия.
without_ntinboolean`true` → только позиции без НТИН (`ntin` = null), независимо от наличия штрихкода — шире, чем поле ответа `ntin_missing` (то требует непустой `barcode`). Удобно считать «сколько осталось доделать» по `meta.total`. Компонуется со всеми режимами и фильтрами (`statuses[]`, `search` и т.д.).
batch_idstringФильтр по UUID батча bulk-приёма (last_batch_id). Компонуется со всеми режимами.
per_pageinteger
pageinteger

Поля ответа

ПолеТипОбяз.NullableОписание
dataany[]
metaany

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_keystringКлюч идемпотентности (эквивалент заголовка Idempotency-Key). Повтор → тот же батч (200). ⚠️ Пространство ключей ОБЩЕЕ с массовым удалением: ключ, уже занятый операцией другого типа, отдаёт `409 idempotency_key_conflict`.
sync_tokenstringМетка прогона полной синхронизации. Проставляется на КАЖДУЮ упомянутую позицию — включая те, по которым работы не было (совпадение с каталогом Kaspi). Нужна, чтобы потом удалить остаток «всё, чего в этой заливке не было». Заливка из нескольких запросов шлёт один и тот же токен во всех. ⚠️ Повтор с тем же `idempotency_key` токен НЕ обновляет — берите точку отсчёта ДО первого запроса прогона. ⛔ Не заменяйте это фильтром по времени изменения: `updated_at` не двигается у позиций, совпавших с каталогом, и двигается у тронутых синхронизацией — фильтр по времени удалил бы живое и пощадил мёртвое.
run_totalintegerСколько позиций прогон пришлёт ВСЕГО (не в этом запросе). Шлётся вместе с `sync_token` — можно в каждом запросе прогона, значение одинаковое. ⛔ Без него удаление по фильтру недоступно: сверять факт будет не с чем, и `POST /catalog/bulk-delete` ответит `422 catalog_delete_filter_invalid` с `reason: run_not_closed`. ⛔ Объявляется ОДИН раз, в начале прогона: повтор с другим значением → `422 catalog_run_total_conflict`. Начали другой прогон — возьмите новый `sync_token`.
itemsobject[]Да
items.namestringДа
items.selling_pricenumberДа
items.unit_idintegerДа
items.image_idstringID изображения из upload-image (exists).
items.barcodestring
items.ntinstringНТИН кандидата Нацкаталога (из POST /catalog/scan).
items.gtinstringGTIN кандидата Нацкаталога (GS1).
items.external_refstringКлиентская ссылка (1С).
items.from_catalogboolean

Поля ответа

ПолеТипОбяз.NullableОписание
dataany[]
rejectedany[]
batchany

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).

Параметры запроса

ПолеТипОбяз.Описание
imagestringДаФайл изображения: JPEG или PNG, макс 6144 KB (6 МБ), стороны 64…6000 px, площадь ≤12 Мпикс.

Поля ответа

ПолеТипОбяз.NullableОписание
image_idstring

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` его нет.

Параметры запроса

ПолеТипОбяз.Описание
namestring
selling_pricenumber
unit_idinteger
image_idstringНовый ID изображения (exists).
is_image_deletedbooleantrue — удалить изображение.
barcodestring
ntinstring⚠️ Затирает идентичность Нацкаталога — только при реальном изменении.
gtinstring⚠️ То же предупреждение, что и для ntin.
sync_tokenstringТа же метка прогона, что у POST /catalog. Нужна, если позицию вы ведёте ТОЛЬКО через PATCH: без неё она будет считаться неупомянутой в прогоне и попадёт под удаление остатка. Проставляется независимо от того, менялись ли поля.

Поля ответа

ПолеТипОбяз.NullableОписание
messagestring
catalog_item_idinteger

DELETE /catalog/{id}

Sandbox → `200` (hard delete). Production → `202`: статус позиции становится `deleting`, а в Kaspi она снимается фоновой обработкой. ⚠️ **Ответ приходит раньше, чем товар исчезает из кассы.** Обычно позиция снимается за считаные секунды, но если параллельно идёт массовая операция с каталогом, очередь загружена и снятие займёт дольше. Проверяйте результат чтением `GET /catalog?statuses[]=deleted`, а не сразу после ответа. ⚠️ Позиция остаётся в `deleting` и после временного сбоя — сервис вернётся к ней сам, повторный запрос слать не нужно.

Поля ответа

ПолеТипОбяз.NullableОписание
messagestring
catalog_item_idinteger

Подписки (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)

ПолеТипОбяз.Описание
statusstringТочный фильтр по статусу.
external_subscriber_idstring
phone_numberstring
per_pageinteger
pageinteger

Поля ответа

ПолеТипОбяз.NullableОписание
dataany[]
metaany

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Описание
messagestring
subscriptionany

GET /subscriptions/{id}

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

PUT /subscriptions/{id}

Все поля опциональны. `cart_items` на не-каталог организации → `422`. При передаче `cart_items` сумма пересчитывается. ⛔ Позиция корзины в статусе `deleting` отбивается `422` так же, как при создании — текст в `errors["cart_items.N.catalog_item_id"]`.

Параметры запроса

ПолеТипОбяз.Описание
amountnumber
billing_dayinteger
descriptionstring
subscriber_namestring
external_subscriber_idstring
max_retry_attemptsinteger
retry_interval_hoursinteger
grace_period_daysinteger
metadataobject
cart_itemsany[]

Поля ответа

ПолеТипОбяз.NullableОписание
messagestring
subscriptionany

POST /subscriptions/{id}/pause

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

POST /subscriptions/{id}/resume

Возобновляет 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_pageinteger
pageinteger

Поля ответа

ПолеТипОбяз.NullableОписание
dataany[]
metaobject
meta.current_pageinteger
meta.totalinteger
meta.per_pageinteger

Webhooks

Webhooks настраиваются через личный кабинет ApiPay.kz (Настройки > Подключение). При создании webhook вы получите secret для верификации подписи (HMAC-SHA256).

События

Когда приходит вебхук

Этот раздел перечисляет события, при которых ApiPay шлёт вебхук, и поясняет, в каком статусе он приходит. Технические статусы processing/cancelling вебхуков не порождают.

СобытиеСтатусКогда
invoice.status_changedpendingСчёт создан в Kaspi и ожидает оплату. Для счетов по номеру (POST /invoices) это первый вебхук после 201-ответа со status=processing. Для QR-счетов (POST /invoices/qr) pending-вебхук НЕ отправляется — статус возвращается синхронно в 201-ответе; первый вебхук по QR-счёту — оплата, отмена, истечение или ошибка.
invoice.qr_scannedpendingТолько для QR-счетов: клиент отсканировал QR и оказался на экране оплаты Kaspi (qr_substate=scanned). status остаётся pending — это суб-состояние, а не смена статуса. Шлётся ровно один раз; событие транзиентно — далее придёт paid (оплатил) или cancelled (свернул/закрыл приложение). Не считайте скан гарантией оплаты.
invoice.status_changedpaidСчёт оплачен. Может прийти и ПОСЛЕ cancelled/expired — оплата в последний момент выигрывает гонку (см. «Переходы статусов»).
invoice.status_changedcancelledСчёт отменён: вами через API, кассиром, либо для QR — отменой со стороны клиента (свернул или закрыл приложение Kaspi, не подтвердив оплату). Создание нового QR на той же кассе старый счёт НЕ отменяет — supersede-вебхука больше нет.
invoice.status_changedexpiredСчёт истёк. Phone-счёт — 24 часа в Kaspi. QR-счёт — минуты, и только когда Kaspi сообщил, что ссылка больше не действует, а НЕ по локальному таймеру qr_expires_at.
invoice.status_changederrorТехническая ошибка — счёт финализирован, система больше НЕ повторяет попытки по этому счёту. Причина — в error_code/error_message. Что делать — раздел «Сценарии реагирования».
invoice.status_changedpartially_refundedПервый частичный возврат по счёту (дополнительно к invoice.refunded). Повторные частичные возвраты статус не меняют. Полный возврат статус НЕ меняет — счёт остаётся paid (или partially_refunded, если ранее был частичный) с is_fully_refunded=true.
invoice.refundedcompletedВозврат проведён. Включает возвраты, сделанные кассиром в приложении Kaspi (импортируются автоматически).
invoice.refundedfailedВозврат не удался (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_closedcompletedКассовая смена закрыта. Приходит по операции, принятой запросом POST /cashbox/shifts/close (ответ 202). Ответ Kaspi «смена уже закрыта» тоже считается успехом: целевое состояние достигнуто.
cashbox.shift_close_failedfailedЗакрытие смены не удалось; причина — в operation.error_code. Смена могла остаться открытой. Автоматически повторяйте только при resolution.safe_to_retry=true в GET /cashbox/operations/{id} и только с новым client_operation_id.

Примеры payload

invoice.status_changed

{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "15000.00",
    "subtotal": "16500.00",
    "discount_sum": "1500.00",
    "discount_percentage": "10",
    "status": "paid",
    "description": "Оплата заказа",
    "kaspi_invoice_id": "13234689513",
    "client_name": "Иван Иванов",
    "client_phone": "87071234567",
    "is_sandbox": false,
    "kaspi_source_type": "GOLD",
    "kaspi_sale_type": "Remote",
    "paid_at": "2026-02-12T14:35:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:35:01+00:00"
}
Счёт не обработан (status: error)
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 43,
    "external_order_id": "order_124",
    "amount": "15000.00",
    "status": "error",
    "description": "Оплата заказа",
    "kaspi_invoice_id": null,
    "client_name": null,
    "client_phone": "87071234567",
    "is_sandbox": false,
    "errored_at": "2026-02-12T14:40:00+00:00",
    "error_message": "Этот номер телефона не зарегистрирован в Kaspi. Укажите номер с установленным приложением Kaspi.",
    "error_code": "client_not_found"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:40:01+00:00"
}
Счёт истёк (status: expired)
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 46,
    "external_order_id": "order_125",
    "amount": "15000.00",
    "status": "expired",
    "description": "Оплата заказа",
    "kaspi_invoice_id": "13234689515",
    "client_name": "Иван Иванов",
    "client_phone": "87071234567",
    "is_sandbox": false,
    "expired_at": "2026-02-13T14:35:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-13T14:35:01+00:00"
}
QR-счёт отменён клиентом (status: cancelled)
{
  "event": "invoice.status_changed",
  "invoice": {
    "id": 44,
    "external_order_id": null,
    "amount": "15000.00",
    "status": "cancelled",
    "description": "QR на кассе",
    "kaspi_invoice_id": "13234689514",
    "client_name": null,
    "client_phone": null,
    "is_sandbox": false,
    "cancelled_at": "2026-02-12T14:45:00+00:00"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T14:45:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — invoice.status_changed.
invoice.idintegerВнутренний ID счёта в ApiPay.
invoice.external_order_idstringдаВаш внешний идентификатор заказа, переданный при создании счёта.
invoice.amountstringСумма счёта.
invoice.subtotalstringдаСумма до применения скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе).
invoice.discount_sumstringдаСумма скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе).
invoice.discount_percentagestringдаПроцент скидки. Только для счетов с корзиной/скидкой.
invoice.statusstringСтатус счёта: pending / paid / cancelled / expired / error / partially_refunded. Статуса refunded не существует — полный возврат оставляет paid + is_fully_refunded=true.
invoice.kaspi_invoice_idstringдаID счёта в Kaspi. Появляется уже при pending (когда счёт создан в Kaspi); null — пока счёт не дошёл до Kaspi.
invoice.client_phonestringНомер телефона клиента.
invoice.kaspi_source_typestringдаИсточник средств клиента: GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее».
invoice.kaspi_sale_typestringдаСпособ приёма счёта: Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее».
invoice.paid_atstringдаВремя оплаты счёта (ISO 8601). Поле отсутствует во всех статусах, кроме paid (а не null до оплаты).
invoice.error_messagestringдаЧеловекочитаемая причина. При status=error присутствует всегда; при status=cancelled — только если заполнена (обычно отсутствует при отмене клиентом или через API). В статусах paid/pending/expired поле отсутствует.
invoice.error_codestringдаСтабильный snake_case-код из каталога (раздел "Коды ошибок"). Присутствует только если не null и только при status=error/cancelled. Стройте switch-логику по нему, а не по тексту.
invoice.cancelled_atstringдаВремя перехода в cancelled (ISO 8601). Присутствует только при соответствующем статусе.
invoice.expired_atstringдаВремя перехода в expired (ISO 8601). Присутствует только при соответствующем статусе.
invoice.errored_atstringдаВремя перехода в error (ISO 8601). Присутствует только при соответствующем статусе.
sourcestringдаНазвание API-ключа, через который создан счёт.
timestampstringВремя отправки события (ISO 8601).

invoice.qr_scanned

{
  "event": "invoice.qr_scanned",
  "invoice": {
    "id": 108565,
    "external_order_id": "order-123",
    "amount": "1500.00",
    "status": "pending",
    "qr_substate": "scanned",
    "description": "Оплата заказа",
    "kaspi_invoice_id": "15977100656",
    "client_name": null,
    "client_phone": null,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-06-14T14:37:00+00:00"
}
ПолеТипNullableОписание
eventstringТип события — invoice.qr_scanned.
invoice.idintegerВнутренний ID счёта в ApiPay.
invoice.external_order_idstringдаВаш внешний идентификатор заказа, переданный при создании счёта.
invoice.amountstringСумма счёта в тенге.
invoice.statusstringВсегда pending — qr_scanned не меняет статус, а сообщает о суб-состоянии «на экране оплаты».
invoice.qr_substatestringМаркер суб-состояния QR — scanned (клиент отсканировал QR и на экране оплаты).
invoice.descriptionstringдаОписание счёта.
invoice.kaspi_invoice_idstringдаID счёта в Kaspi.
invoice.client_namestringдаИмя клиента (для QR-счёта обычно null).
invoice.client_phonestringдаТелефон клиента (для QR-счёта обычно null).
invoice.is_sandboxbooleanПризнак sandbox-счёта.
sourcestringдаНазвание API-ключа, через который создан счёт.
timestampstringВремя отправки события (ISO 8601, UTC).

invoice.refunded

{
  "event": "invoice.refunded",
  "refund": {
    "id": 5,
    "amount": "2000.00",
    "status": "completed",
    "kaspi_refund_id": "1126827352",
    "reason": "Возврат товара",
    "created_at": "2026-02-12T10:00:00+00:00",
    "items": [{"catalog_item_id": 12, "name": "Кофе", "price": "1000.00", "count": 2, "amount": "2000.00"}]
  },
  "invoice": {
    "id": 42,
    "external_order_id": "order_123",
    "amount": "5000.00",
    "subtotal": "5500.00",
    "discount_sum": "500.00",
    "total_refunded": "2000.00",
    "available_for_refund": 3000,
    "is_fully_refunded": false,
    "is_sandbox": false,
    "status": "paid",
    "kaspi_invoice_id": "13234689513"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T10:00:01+00:00"
}
Возврат не удался (refund.status: failed)
{
  "event": "invoice.refunded",
  "refund": {
    "id": 6,
    "amount": "2000.00",
    "status": "failed",
    "kaspi_refund_id": null,
    "reason": "Возврат товара",
    "created_at": "2026-02-12T10:00:00+00:00",
    "error_code": "refund_window_expired"
  },
  "invoice": {
    "id": 42, "external_order_id": "order_123", "amount": "5000.00",
    "total_refunded": "0.00", "available_for_refund": "5000.00",
    "is_fully_refunded": false, "is_sandbox": false,
    "status": "paid", "kaspi_invoice_id": "13234689513"
  },
  "source": "My API Key",
  "timestamp": "2026-02-12T10:00:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — invoice.refunded.
refund.idintegerID возврата.
refund.amountstringСумма возврата.
refund.statusstringpending / processing / completed / failed. Вебхук приходит на completed И на failed.
refund.kaspi_refund_idstringдаID возврата в Kaspi; null при неудаче.
refund.reasonstringдаПричина возврата.
refund.created_atstringВремя создания возврата (ISO 8601).
refund.error_codestringдаТолько при status=failed. Например refund_window_expired — истёк срок возврата (~14 дней). Поля error_message в вебхуке нет by design — текст смотрите в GET /invoices/{id}/refunds или резолвите код по каталогу.
refund.itemsarrayдаПозиции возврата (только для позиционных возвратов): catalog_item_id, name, price, count, amount.
invoice.idintegerВнутренний ID счёта в ApiPay.
invoice.external_order_idstringдаВаш внешний идентификатор заказа.
invoice.amountstringСумма счёта.
invoice.subtotalstringСумма счёта до применения скидки.
invoice.discount_sumstringСумма скидки по счёту.
invoice.total_refundedstringСуммарно возвращено по счёту на текущий момент.
invoice.available_for_refundnumberСумма, ещё доступная для возврата. Приходит числом (float), в отличие от amount и total_refunded, которые передаются строками.
invoice.is_fully_refundedbooleantrue, если счёт возвращён полностью.
invoice.is_sandboxbooleanСчёт создан в sandbox-режиме.
invoice.statusstringСтатус счёта после возврата. Полный возврат статус НЕ меняет (остаётся paid — или partially_refunded, если ранее был частичный) + is_fully_refunded=true; первый частичный переводит в partially_refunded (и дополнительно приходит invoice.status_changed).
invoice.kaspi_invoice_idstringдаID счёта в Kaspi.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.payment_succeeded

{
  "event": "subscription.payment_succeeded",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "invoice_id": 200,
  "amount": "5000.00",
  "paid_at": "2026-02-01T12:00:00+00:00",
  "source": "My API Key",
  "timestamp": "2026-02-01T12:00:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.payment_succeeded.
subscription.idintegerID подписки.
subscription.external_subscriber_idstringдаВаш внешний идентификатор подписчика.
subscription.phone_numberstringНомер телефона подписчика.
subscription.subscriber_namestringдаИмя подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.billing_periodstringПериод списания (например, monthly).
subscription.statusstringСтатус подписки (например, active).
subscription.next_billing_atstringдаДата следующего списания (ISO 8601).
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
invoice_idintegerID счёта, по которому прошёл платёж.
amountstringСумма успешного платежа.
paid_atstringВремя оплаты (ISO 8601).
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.payment_failed

{
  "event": "subscription.payment_failed",
  "subscription": {
    "id": 10,
    "phone_number": "87071234567",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "failed_attempts": 2,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "invoice_id": 201,
  "amount": "5000.00",
  "reason": "Invoice expired",
  "attempt_number": 2,
  "source": "My API Key",
  "timestamp": "2026-02-02T12:00:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.payment_failed.
subscription.idintegerID подписки.
subscription.phone_numberstringНомер телефона подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.billing_periodstringПериод списания (например, monthly).
subscription.statusstringСтатус подписки (например, active).
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
invoice_idintegerID счёта, по которому не прошёл платёж.
amountstringСумма неуспешного платежа.
reasonstringдаПричина неуспеха: принимает "Invoice expired" или "Invoice cancelled".
attempt_numberintegerНомер текущей попытки списания.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.grace_period_started

{
  "event": "subscription.grace_period_started",
  "subscription": {
    "id": 10,
    "phone_number": "87071234567",
    "amount": "5000.00",
    "status": "active",
    "failed_attempts": 3,
    "in_grace_period": true,
    "is_sandbox": false
  },
  "grace_period_days": 3,
  "expires_at": "2026-02-05T12:00:00+00:00",
  "source": "My API Key",
  "timestamp": "2026-02-02T12:00:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.grace_period_started.
subscription.idintegerID подписки.
subscription.phone_numberstringНомер телефона подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.statusstringСтатус подписки (например, active).
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде (здесь true).
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
grace_period_daysintegerДлительность льготного периода в днях.
expires_atstringКогда истекает льготный период (ISO 8601).
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.expired

{
  "event": "subscription.expired",
  "subscription": {
    "id": 10,
    "phone_number": "87071234567",
    "amount": "5000.00",
    "status": "expired",
    "next_billing_at": null,
    "failed_attempts": 3,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-05T12:00:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.expired.
subscription.idintegerID подписки.
subscription.phone_numberstringНомер телефона подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.statusstringСтатус подписки (здесь expired).
subscription.next_billing_atstringдаДата следующего списания (null для истёкшей подписки).
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.created

{
  "event": "subscription.created",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-01T12:00:01+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.created.
subscription.idintegerID подписки.
subscription.external_subscriber_idstringдаВаш внешний идентификатор подписчика.
subscription.phone_numberstringНомер телефона подписчика.
subscription.subscriber_namestringдаИмя подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.billing_periodstringПериод списания (например, monthly).
subscription.statusstringСтатус подписки (здесь active).
subscription.next_billing_atstringдаДата следующего списания (ISO 8601).
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.paused

{
  "event": "subscription.paused",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "paused",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-10T09:00:00+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.paused.
subscription.idintegerID подписки.
subscription.external_subscriber_idstringдаВаш внешний идентификатор подписчика.
subscription.phone_numberstringНомер телефона подписчика.
subscription.subscriber_namestringдаИмя подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.billing_periodstringПериод списания (например, monthly).
subscription.statusstringСтатус подписки (здесь paused).
subscription.next_billing_atstringдаДата следующего списания (ISO 8601).
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.resumed

{
  "event": "subscription.resumed",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "active",
    "next_billing_at": "2026-03-15T09:30:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-15T09:30:00+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.resumed.
subscription.idintegerID подписки.
subscription.external_subscriber_idstringдаВаш внешний идентификатор подписчика.
subscription.phone_numberstringНомер телефона подписчика.
subscription.subscriber_namestringдаИмя подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.billing_periodstringПериод списания (например, monthly).
subscription.statusstringСтатус подписки (здесь active).
subscription.next_billing_atstringдаДата следующего списания (ISO 8601), пересчитанная от момента возобновления.
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

subscription.cancelled

{
  "event": "subscription.cancelled",
  "subscription": {
    "id": 10,
    "external_subscriber_id": "CLIENT-001",
    "phone_number": "87071234567",
    "subscriber_name": "Иван Иванов",
    "amount": "5000.00",
    "billing_period": "monthly",
    "status": "cancelled",
    "next_billing_at": "2026-03-01T00:00:00+00:00",
    "failed_attempts": 0,
    "in_grace_period": false,
    "is_sandbox": false
  },
  "source": "My API Key",
  "timestamp": "2026-02-20T18:00:00+00:00"
}
ПолеТипNullableОписание
eventstringТип события — subscription.cancelled.
subscription.idintegerID подписки.
subscription.external_subscriber_idstringдаВаш внешний идентификатор подписчика.
subscription.phone_numberstringНомер телефона подписчика.
subscription.subscriber_namestringдаИмя подписчика.
subscription.amountstringСумма платежа по подписке.
subscription.billing_periodstringПериод списания (например, monthly).
subscription.statusstringСтатус подписки (здесь cancelled).
subscription.next_billing_atstringдаДата следующего списания (ISO 8601). НЕ обнуляется при отмене — сохраняет последнее значение; счета больше не выставляются.
subscription.failed_attemptsintegerКоличество подряд неуспешных попыток списания.
subscription.in_grace_periodbooleanНаходится ли подписка в льготном периоде.
subscription.is_sandboxbooleanПодписка создана в sandbox-режиме.
sourcestringдаНазвание API-ключа.
timestampstringВремя отправки события (ISO 8601).

receipt.issued

{
  "event": "receipt.issued",
  "receipt": {
    "id": 4210,
    "client_operation_id": "pos-cash-0042",
    "payment_type": 3,
    "status": "issued",
    "fpd": "000000000000",
    "operation_id": "KKM00000000",
    "link": "https://receipt.kaspi.kz/preview/cashier?extTranId=KKM00000000",
    "shift_number": 106,
    "total_price": "10.00",
    "error_code": null,
    "error_message": null
  },
  "timestamp": "2026-07-12T16:25:43+00:00"
}
ПолеТипNullableОписание
eventstringТип события — receipt.issued.
receipt.idintegerВнутренний ID чека в ApiPay.
receipt.client_operation_idstringдаВаш ключ идемпотентности, переданный при выбивании чека.
receipt.payment_typeintegerТип оплаты: 3 — наличные, 5 — POS другого банка.
receipt.statusstringСтатус чека — issued (успешно выбит).
receipt.fpdstringдаФискальный признак документа (ФПД) от Kaspi OFD.
receipt.operation_idstringдаИдентификатор операции в Kaspi.
receipt.shift_numberintegerдаНомер смены кассира.
receipt.total_pricestringдаСумма чека.
receipt.error_codestringдаДля issued всегда null.
receipt.error_messagestringдаДля issued всегда null.
timestampstringВремя отправки события (ISO 8601, UTC +00:00).

receipt.failed

{
  "event": "receipt.failed",
  "receipt": {
    "id": 4211,
    "client_operation_id": "pos-cash-0043",
    "payment_type": 3,
    "status": "failed",
    "fpd": null,
    "operation_id": null,
    "link": null,
    "shift_number": null,
    "total_price": "10.00",
    "error_code": "shift_closed",
    "error_message": "Смена кассира закрыта — откройте смену в приложении Kaspi Pay и повторите."
  },
  "timestamp": "2026-07-12T16:26:10+00:00"
}
ПолеТипNullableОписание
eventstringТип события — receipt.failed.
receipt.idintegerВнутренний ID чека в ApiPay.
receipt.client_operation_idstringдаВаш ключ идемпотентности, переданный при выбивании чека.
receipt.payment_typeintegerТип оплаты: 3 — наличные, 5 — POS другого банка.
receipt.statusstringСтатус чека — failed.
receipt.fpdstringдаПри failed всегда null — фискальный документ не создан.
receipt.operation_idstringдаПри failed всегда null.
receipt.shift_numberintegerдаПри failed обычно null.
receipt.total_pricestringдаСумма чека.
receipt.error_codestringдаКод причины: shift_closed (закрыта смена), item_not_fiscal (позиция без НТИН), rfo_missing, receipt_kaspi_error, receipt_dispatch_error. Стройте switch по нему, не по тексту.
receipt.error_messagestringдаЧеловекочитаемое пояснение ошибки.
timestampstringВремя отправки события (ISO 8601, UTC +00:00).

qr_refund.identified

{
  "event": "qr_refund.identified",
  "qr_refund": {
    "id": 42,
    "status": "customer_identified",
    "client_name": "Иван И.",
    "expires_at": "2026-07-27T17:27:09+00:00",
    "is_sandbox": false
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:22:31+00:00"
}
ПолеТипNullableОписание
eventstringТип события — qr_refund.identified.
qr_refund.idintegerID сессии QR-возврата.
qr_refund.statusstringВсегда customer_identified. Статус берётся из СОБЫТИЯ, а не из живой сессии: ретрай не принесёт противоречивый payload.
qr_refund.client_namestringдаИмя покупателя от Kaspi. null, пока покупатель не подтвердил.
qr_refund.expires_atstringдаСрок действия ссылки (ISO 8601).
qr_refund.is_sandboxbooleanСессия создана в песочнице.
sourcestringдаИмя API-ключа, которым создана сессия.
timestampstringВремя отправки события (ISO 8601).

qr_refund.completed

{
  "event": "qr_refund.completed",
  "qr_refund": {
    "id": 42,
    "status": "completed",
    "client_name": "Иван И.",
    "expires_at": "2026-07-27T17:27:09+00:00",
    "is_sandbox": false,
    "refunded_amount": "500.00",
    "receipt_url": "https://receipt.kaspi.kz/preview/cashier?extTranId=KKM00000000"
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:24:02+00:00"
}
ПолеТипNullableОписание
eventstringТип события — qr_refund.completed.
qr_refund.idintegerID сессии QR-возврата.
qr_refund.statusstringВсегда completed.
qr_refund.client_namestringдаИмя покупателя от Kaspi. null, пока покупатель не подтвердил.
qr_refund.expires_atstringдаСрок действия ссылки (ISO 8601).
qr_refund.is_sandboxbooleanСессия создана в песочнице.
qr_refund.refunded_amountstringдаВозвращённая сумма. Приходит ТОЛЬКО в qr_refund.completed.
qr_refund.receipt_urlstringдаСсылка на чек возврата в Kaspi. Только в qr_refund.completed.
sourcestringдаИмя API-ключа, которым создана сессия.
timestampstringВремя отправки события (ISO 8601).

qr_refund.expired

{
  "event": "qr_refund.expired",
  "qr_refund": {
    "id": 43,
    "status": "expired",
    "client_name": null,
    "expires_at": "2026-07-27T17:20:00+00:00",
    "is_sandbox": false
  },
  "source": "CRM integration",
  "timestamp": "2026-07-27T17:20:05+00:00"
}
ПолеТипNullableОписание
eventstringТип события — qr_refund.expired.
qr_refund.idintegerID сессии QR-возврата.
qr_refund.statusstringВсегда expired.
qr_refund.client_namestringдаИмя покупателя от Kaspi. null, пока покупатель не подтвердил.
qr_refund.expires_atstringдаСрок действия ссылки (ISO 8601).
qr_refund.is_sandboxbooleanСессия создана в песочнице.
sourcestringдаИмя API-ключа, которым создана сессия.
timestampstringВремя отправки события (ISO 8601).

cashbox.shift_closed

{
  "event": "cashbox.shift_closed",
  "operation": {
    "id": 812,
    "operation_type": "close_shift",
    "status": "completed",
    "shift_number": 106,
    "error_code": null
  },
  "timestamp": "2026-08-10T16:25:43+00:00"
}
ПолеТипNullableОписание
eventstringТип события — cashbox.shift_closed.
operation.idintegerID кассовой операции — тот же, что вернул ответ 202 и по которому идёт поллинг GET /cashbox/operations/{id}. Дедуплицируйте по паре (event, operation.id).
operation.operation_typestringТип операции. Сейчас всегда close_shift.
operation.statusstringТерминальный статус операции — completed.
operation.shift_numberintegerдаНомер закрытой смены.
operation.error_codestringдаПри успехе всегда null.
timestampstringВремя события в UTC.

cashbox.shift_close_failed

{
  "event": "cashbox.shift_close_failed",
  "operation": {
    "id": 813,
    "operation_type": "close_shift",
    "status": "failed",
    "shift_number": 106,
    "error_code": "cashbox_operation_failed"
  },
  "timestamp": "2026-08-10T16:26:11+00:00"
}
ПолеТипNullableОписание
eventstringТип события — cashbox.shift_close_failed.
operation.idintegerID кассовой операции. Дедуплицируйте по паре (event, operation.id).
operation.operation_typestringТип операции. Сейчас всегда close_shift.
operation.statusstringТерминальный статус операции — failed.
operation.shift_numberintegerдаНомер смены, которую пытались закрыть.
operation.error_codestringдаПричина отказа — слаг вида cashbox_*. Повторяйте закрытие только новым client_operation_id: прежний ключ после отказа не освобождается.
timestampstringВремя события в 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}.

Допустимые переходы

Никогда не происходят

Без вебхука

Сценарии реагирования

Когда вебхук приносит status=error (счёт) или status=failed (возврат) — операция финализирована: сервис её больше не повторяет — «повторить» здесь означает «создать новую операцию». Пока счёт в processing — сервис сам доводит счёт до Kaspi и распределяет выставление во времени, вмешиваться не нужно. Счёт может держаться в processing дольше часа — это не зависание.

Отдельный случай — деактивированный кассир: после деактивации возвраты по счетам, оплаченным через него, через API не проходят — запрос принимается, но возврат завершается статусом failed (вебхук invoice.refunded). Такие возвраты проводят вручную в приложении Kaspi Pay; нужные возвраты проводите до деактивации кассира.

ОшибкаЧто произошлоЧто делает системаЧто делать вам
client_not_foundНомер телефона не зарегистрирован в KaspiФинализирует счёт сразу, без ретраевЗапросите у клиента другой номер и создайте новый счёт
network_unavailableСеть/Kaspi были недоступныРетраила сама; вебхук означает, что ретраи исчерпаныСоздайте новый счёт/возврат через 1–2 минуты
session_transientВременный сбой сессии кассираАвтоматически инвалидировала сессию и ретраилаСоздайте новый счёт позже; если повторяется — переподключите кассира в ЛК
kaspi_throttledKaspi ограничил частоту запросов кассыСервис распределяет выставление во времени: счета выставляются медленнее обычного; вебхук = финализацияПока счёт в processing — ничего. После error — новый счёт через 2–3 минуты; снизьте темп создания счетов
organization_not_configuredК организации не подключён кассир KaspiФинализирует сразуПодключите кассира: ЛК → Настройки → Авторизация Kaspi
invoice_already_paidПопытка отменить уже оплаченный счётОтмену остановила; деньги полученыНе отменяйте; если нужно вернуть деньги — создайте возврат
invoice_already_cancelledСчёт уже отменёнНичего: желаемое состояние уже достигнуто
invoice_not_found_in_kaspiKaspi не нашёл счёт при отменеФинализирует 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

Retry Policy

Circuit breaker

Если ваш endpoint стабильно недоступен, отправка на ключ приостанавливается: 5 подряд неудач → пауза 5 минут, 10 → 30 минут, 20 → 2 часа, 50 → полное отключение до ручного вмешательства. Вебхуки за время паузы НЕ доотправляются — сверяйте состояние через GET-методы. Любая успешная доставка (или успешный тест-вебхук из ЛК) сбрасывает счётчик. Статус виден в списке API-ключей.

Верификация подписи

Header: X-Webhook-Signature: sha256=<hex>

python

import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# Usage:
# signature = request.headers.get('X-Webhook-Signature')
# is_valid = verify_webhook(request.body, signature, webhook_secret)

php

javascript

import crypto from 'crypto'

function verifyWebhook(payload, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')
  const got = Buffer.from(signature || '')
  const exp = Buffer.from(expected)
  if (got.length !== exp.length) return false
  return crypto.timingSafeEqual(exp, got)
}

// Usage:
// const signature = req.headers['x-webhook-signature']
// const isValid = verifyWebhook(req.rawBody, signature, webhookSecret)

Примеры кода

Закрыть смену и дождаться результата

JavaScript / Node.js

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'))

cURL

# Закрытие асинхронное: 202 -> поллинг операции (или вебхук cashbox.shift_closed).
curl -X POST https://api.apipay.kz/api/v1/cashbox/shifts/close \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "client_operation_id": "close-2026-08-10-01", "shift_number": 106 }'

# 202 -> { "id": 812, "status": "pending", "poll_url": "..." }
curl "https://api.apipay.kz/api/v1/cashbox/operations/812" -H "X-API-Key: YOUR_API_KEY"

Отчёт по смене (ссылка живёт 15 минут)

JavaScript / Node.js

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 + батч)

JavaScript / Node.js

// Повтор с тем же Idempotency-Key вернёт существующий батч (200) без пересоздания.
const res = await fetch('https://api.apipay.kz/api/v1/catalog', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'import-2026-07-11-0001'
  },
  body: JSON.stringify({
    items: [
      { name: 'Фильтр масляный', selling_price: 1800, unit_id: 1, external_ref: '1C-000123' }
    ]
    // альтернатива заголовку: idempotency_key: 'import-2026-07-11-0001'
  })
})
const { batch } = await res.json()
console.log('Batch:', batch?.batch_id, batch?.poll_url)

Python

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.

Прогресс bulk-батча приёма каталога

JavaScript / Node.js

// Агрегированный прогресс батча (poll до pending_remaining == 0).
const res = await fetch(
  'https://api.apipay.kz/api/v1/catalog/batches/8f14e45f-ceea-467e-9a2c-0000000000aa',
  { headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const b = await res.json()
console.log(b.status, b.totals, 'left:', b.pending_remaining)

Python

import requests

# Агрегированный прогресс батча (poll до pending_remaining == 0).
res = requests.get(
    'https://api.apipay.kz/api/v1/catalog/batches/8f14e45f-ceea-467e-9a2c-0000000000aa',
    headers={'X-API-Key': 'YOUR_API_KEY'},
)
b = res.json()
print(b['status'], b['totals'], 'left:', b['pending_remaining'])

cURL

# Агрегированный прогресс батча (poll до pending_remaining == 0).
curl "https://api.apipay.kz/api/v1/catalog/batches/8f14e45f-ceea-467e-9a2c-0000000000aa" \
  -H "X-API-Key: YOUR_API_KEY"
# Чужой/несуществующий/битый UUID → 404 (non-enumeration).

Автономный тест без человека (полный цикл)

cURL

# Полный автономный цикл для ИИ-агента с sandbox-ключом (X-API-Key).
# Критерий успеха: каждый шаг подтверждается записью в /webhook-logs.
KEY="YOUR_SANDBOX_API_KEY"
BASE="https://api.apipay.kz/api/v1"

# 1. Создать sandbox-счёт (external_order_id_idempotency защищает от дублей при ретрае).
INVOICE_ID=$(curl -s -X POST "$BASE/invoices" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87770000001",
    "amount": 5000,
    "description": "Autotest order",
    "external_order_id_idempotency": "autotest-001"
  }' | jq -r '.id')

# 1a. Дождаться статуса pending — счёт создаётся в processing, переход
#     асинхронный (обычно 1-3 секунды). simulate-status требует pending.
#     (QR-счёт из POST /invoices/qr сразу pending — для него шаг не нужен.)
until [ "$(curl -s "$BASE/invoices/$INVOICE_ID" -H "X-API-Key: $KEY" | jq -r '.status')" = "pending" ]; do
  sleep 1
done

# 2. Перевести счёт в paid (симуляция оплаты, только sandbox).
curl -s -X POST "$BASE/invoices/$INVOICE_ID/simulate-status" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{ "status": "paid" }'

# 3. Убедиться, что ушёл вебхук invoice.status_changed (status=paid).
#    Совет: если ответ пуст — проверьте по ?invoice_id=$INVOICE_ID без event;
#    тип события есть в request_body записи.
curl -s "$BASE/webhook-logs?invoice_id=$INVOICE_ID&event=invoice.status_changed" \
  -H "X-API-Key: $KEY" | jq '.data[] | {event, status, response_status}'

# 4. Частичный возврат (в песочнице проходит без Kaspi; статус paid -> partially_refunded).
curl -s -X POST "$BASE/invoices/$INVOICE_ID/refund" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{ "amount": 2000 }'

# 5. Проверить вебхуки возврата: invoice.refunded + invoice.status_changed (partially_refunded).
curl -s "$BASE/webhook-logs?invoice_id=$INVOICE_ID" \
  -H "X-API-Key: $KEY" | jq '.data[] | {event, status}'

# Ветки других статусов (каждый — новый sandbox-счёт, дождаться pending как в шаге 1a):
#   simulate-status { "status": "cancelled" }  -> invoice.status_changed (cancelled)
#   simulate-status { "status": "expired" }    -> invoice.status_changed (expired)
#   simulate-status { "status": "error", "error_message": "..." }
#                                              -> error_code: sandbox_simulated_error
# QR-счёт: POST /invoices/qr с телом { "amount": 5000, "description": "QR autotest" }
#   (phone_number не нужен; счёт сразу pending) + simulate-status { "status": "qr_scanned" }:
#   -> invoice.qr_scanned (qr_substate=scanned, статус остаётся pending)
#
# Sandbox-вебхуки: 3 попытки, backoff 5с/15с. Успех = любой HTTP 2xx.

Создание счёта

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/invoices', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: 10000,
    phone_number: '87001234567',
    description: 'Payment for order #123'
  })
})

const data = await response.json()
console.log('Invoice created:', data.id)

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'amount': 10000,
        'phone_number': '87001234567',
        'description': 'Payment for order #123'
    }
)

data = response.json()
print(f"Invoice created: {data['id']}")

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 10000,
        'phone_number' => '87001234567',
        'description' => 'Payment for order #123'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
echo "Invoice created: " . $response['id'];
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/invoices \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 10000,
    "phone_number": "87001234567",
    "description": "Payment for order #123"
  }'

Создание счёта с корзиной

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/invoices', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone_number: '87001234567',
    description: 'Cart order',
    cart_items: [
      { catalog_item_id: 1, count: 2, price: 4500.00 },
      { catalog_item_id: 5, count: 3 }
    ],
    discount_percentage: 10
  })
})
// Response includes subtotal, discount_sum, discount_percentage

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'phone_number': '87001234567',
        'description': 'Cart order',
        'cart_items': [
            {'catalog_item_id': 1, 'count': 2, 'price': 4500.00},
            {'catalog_item_id': 5, 'count': 3}
        ],
        'discount_percentage': 10
    }
)
data = response.json()

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '87001234567',
        'description' => 'Cart order',
        'cart_items' => [
            ['catalog_item_id' => 1, 'count' => 2, 'price' => 4500.00],
            ['catalog_item_id' => 5, 'count' => 3]
        ],
        'discount_percentage' => 10
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/invoices \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87001234567",
    "description": "Cart order",
    "cart_items": [
      { "catalog_item_id": 1, "count": 2, "price": 4500.00 },
      { "catalog_item_id": 5, "count": 3 }
    ],
    "discount_percentage": 10
  }'

Создание подписки

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/subscriptions', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone_number: '87001234567',
    amount: 5000,
    billing_period: 'monthly',
    description: 'Monthly subscription'
  })
})
const data = await response.json()
console.log('Subscription:', data.subscription.id)

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/subscriptions',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'phone_number': '87001234567',
        'amount': 5000,
        'billing_period': 'monthly',
        'description': 'Monthly subscription'
    }
)
data = response.json()
print(f"Subscription: {data['subscription']['id']}")

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '87001234567',
        'amount' => 5000,
        'billing_period' => 'monthly',
        'description' => 'Monthly subscription'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
echo "Subscription: " . $response['subscription']['id'];
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/subscriptions \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87001234567",
    "amount": 5000,
    "billing_period": "monthly",
    "description": "Monthly subscription"
  }'

Создание подписки с корзиной

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/subscriptions', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone_number: '87001234567',
    billing_period: 'monthly',
    description: 'Monthly cart subscription',
    cart_items: [
      { catalog_item_id: 1, count: 2 },
      { catalog_item_id: 5, count: 1 }
    ]
  })
})
const data = await response.json()

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/subscriptions',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'phone_number': '87001234567',
        'billing_period': 'monthly',
        'description': 'Monthly cart subscription',
        'cart_items': [
            {'catalog_item_id': 1, 'count': 2},
            {'catalog_item_id': 5, 'count': 1}
        ]
    }
)
data = response.json()

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '87001234567',
        'billing_period' => 'monthly',
        'description' => 'Monthly cart subscription',
        'cart_items' => [
            ['catalog_item_id' => 1, 'count' => 2],
            ['catalog_item_id' => 5, 'count' => 1]
        ]
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/subscriptions \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87001234567",
    "billing_period": "monthly",
    "description": "Monthly cart subscription",
    "cart_items": [
      { "catalog_item_id": 1, "count": 2 },
      { "catalog_item_id": 5, "count": 1 }
    ]
  }'

Загрузка изображения и создание товара

JavaScript / Node.js

// Step 1: Upload image
const formData = new FormData()
formData.append('image', imageFile)

const uploadRes = await fetch('https://api.apipay.kz/api/v1/catalog/upload-image', {
  method: 'POST',
  headers: { 'X-API-Key': 'YOUR_API_KEY' },
  body: formData
})
const { image_id } = await uploadRes.json()

// Step 2: Create product with image
const productRes = await fetch('https://api.apipay.kz/api/v1/catalog', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    items: [{
      name: 'Coffee Latte',
      selling_price: 1800,
      unit_id: 1,
      image_id
    }]
  })
})

Python

import requests

# Step 1: Upload image
upload_res = requests.post(
    'https://api.apipay.kz/api/v1/catalog/upload-image',
    headers={'X-API-Key': 'YOUR_API_KEY'},
    files={'image': open('product.jpg', 'rb')}
)
image_id = upload_res.json()['image_id']

# Step 2: Create product with image
product_res = requests.post(
    'https://api.apipay.kz/api/v1/catalog',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'items': [{
            'name': 'Coffee Latte',
            'selling_price': 1800,
            'unit_id': 1,
            'image_id': image_id
        }]
    }
)

PHP

 true,
    CURLOPT_HTTPHEADER => ['X-API-Key: YOUR_API_KEY'],
    CURLOPT_POSTFIELDS => ['image' => $cfile],
    CURLOPT_RETURNTRANSFER => true
]);
$imageId = json_decode(curl_exec($ch), true)['image_id'];
curl_close($ch);

// Step 2: Create product with image
$ch = curl_init('https://api.apipay.kz/api/v1/catalog');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'items' => [[
            'name' => 'Coffee Latte',
            'selling_price' => 1800,
            'unit_id' => 1,
            'image_id' => $imageId
        ]]
    ]),
    CURLOPT_RETURNTRANSFER => true
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);

cURL

# Step 1: Upload image
IMAGE_ID=$(curl -s -X POST https://api.apipay.kz/api/v1/catalog/upload-image \
  -H "X-API-Key: YOUR_API_KEY" \
  -F "image=@product.jpg" | jq -r '.image_id')

# Step 2: Create product with image
curl -X POST https://api.apipay.kz/api/v1/catalog \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"items\": [{
      \"name\": \"Coffee Latte\",
      \"selling_price\": 1800,
      \"unit_id\": 1,
      \"image_id\": \"$IMAGE_ID\"
    }]
  }"

Возврат средств

JavaScript / Node.js

// Full refund
await fetch('https://api.apipay.kz/api/v1/invoices/42/refund', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ reason: 'Customer request' })
})

// Partial refund
await fetch('https://api.apipay.kz/api/v1/invoices/42/refund', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ amount: 5000, reason: 'Partial return' })
})

Python

import requests

# Full refund
requests.post(
    'https://api.apipay.kz/api/v1/invoices/42/refund',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'reason': 'Customer request'}
)

# Partial refund
requests.post(
    'https://api.apipay.kz/api/v1/invoices/42/refund',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'amount': 5000, 'reason': 'Partial return'}
)

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'reason' => 'Customer request'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);
curl_exec($ch);
curl_close($ch);

// Partial refund
$ch = curl_init('https://api.apipay.kz/api/v1/invoices/42/refund');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 5000,
        'reason' => 'Partial return'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);
curl_exec($ch);
curl_close($ch);

cURL

# Full refund
curl -X POST https://api.apipay.kz/api/v1/invoices/42/refund \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Customer request"}'

# Partial refund
curl -X POST https://api.apipay.kz/api/v1/invoices/42/refund \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000, "reason": "Partial return"}'

Создание QR-счёта

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/invoices/qr', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: 5000,
    description: 'Заказ №123',
    external_order_id: 'order-123'
  })
})

const data = await response.json()
// Покажите QR на экране кассы
document.getElementById('qr').src = data.qr_image_url
// или используйте qr_token_url для своего рендеринга
console.log('QR expires at:', data.qr_expires_at)

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices/qr',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'amount': 5000,
        'description': 'Заказ №123',
        'external_order_id': 'order-123'
    }
)

data = response.json()
# Покажите QR на экране кассы: data['qr_image_url']
print(f"QR expires at: {data['qr_expires_at']}")

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 5000,
        'description' => 'Заказ №123',
        'external_order_id' => 'order-123'
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// Покажите QR на экране кассы: $response['qr_image_url']
echo "QR expires at: " . $response['qr_expires_at'];
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/invoices/qr \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "description": "Заказ №123",
    "external_order_id": "order-123"
  }'

QR-счёт с корзиной

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/invoices/qr', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    description: 'Заказ №123',
    cart_items: [
      { catalog_item_id: 608400, count: 2, price: 1500 }
    ],
    discount_percentage: 10
  })
})

const data = await response.json()
// data.qr_image_url — готовый PNG, data.qr_token_url — Kaspi-ссылка

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/invoices/qr',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'description': 'Заказ №123',
        'cart_items': [
            {'catalog_item_id': 608400, 'count': 2, 'price': 1500}
        ],
        'discount_percentage': 10
    }
)
data = response.json()

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'description' => 'Заказ №123',
        'cart_items' => [
            ['catalog_item_id' => 608400, 'count' => 2, 'price' => 1500]
        ],
        'discount_percentage' => 10
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/invoices/qr \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Заказ №123",
    "cart_items": [
      { "catalog_item_id": 608400, "count": 2, "price": 1500 }
    ],
    "discount_percentage": 10
  }'

Проверка номера клиента

JavaScript / Node.js

const response = await fetch('https://api.apipay.kz/api/v1/clients/check', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ phone: '77001234567' })
})

const data = await response.json()
// { phone: '87001234567', has_kaspi: true, client_name: 'Иван И.' }
if (!data.has_kaspi) {
  // Клиент не зарегистрирован в Kaspi — попросите другой номер
}

Python

import requests

response = requests.post(
    'https://api.apipay.kz/api/v1/clients/check',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'phone': '77001234567'}
)

data = response.json()
# {'phone': '87001234567', 'has_kaspi': True, 'client_name': 'Иван И.'}
if not data['has_kaspi']:
    pass  # Клиент не зарегистрирован в Kaspi

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode(['phone' => '77001234567']),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// ['phone' => '87001234567', 'has_kaspi' => true, 'client_name' => 'Иван И.']
curl_close($ch);

cURL

curl -X POST https://api.apipay.kz/api/v1/clients/check \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone": "77001234567"}'

Превью фискального чека

JavaScript / Node.js

const res = await fetch('https://api.apipay.kz/api/v1/receipts/preview', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ payment_type: 3, total_price: 10 })
})
const { data } = await res.json()
console.log('Preview lines:', data)

Python

import requests

res = requests.post(
    'https://api.apipay.kz/api/v1/receipts/preview',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={'payment_type': 3, 'total_price': 10}
)
lines = res.json()['data']
print('Preview lines:', lines)

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'payment_type' => 3,
        'total_price' => 10
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// $response['data'] — строки превью чека
curl_close($ch);

cURL

# Синхронное превью строк чека для UI. payment_type: 3 — наличные, 5 — POS другого банка.
curl -X POST https://api.apipay.kz/api/v1/receipts/preview \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_type": 3,
    "total_price": 10
  }'
# 200 → { "data": [ { "Title": "Способ оплаты", "Subtitle": "Наличные", "isBoldText": false } ] }

Выбить фискальный чек

JavaScript / Node.js

const res = await fetch('https://api.apipay.kz/api/v1/receipts', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    payment_type: 3,
    client_operation_id: 'pos-cash-0042',
    received_amt: 5000,
    cart_items: [
      { catalog_item_id: 1, quantity: 2, price: 1800 },
      { catalog_item_id: 5, quantity: 1 }
    ]
  })
})
const receipt = await res.json()
// 202 pending → опрашивайте GET /receipts/{id} или ждите вебхук
console.log('Receipt queued:', receipt.id, receipt.status)

Python

import requests

res = requests.post(
    'https://api.apipay.kz/api/v1/receipts',
    headers={
        'X-API-Key': 'YOUR_API_KEY',
        'Content-Type': 'application/json'
    },
    json={
        'payment_type': 3,
        'client_operation_id': 'pos-cash-0042',
        'received_amt': 5000,
        'cart_items': [
            {'catalog_item_id': 1, 'quantity': 2, 'price': 1800},
            {'catalog_item_id': 5, 'quantity': 1}
        ]
    }
)
receipt = res.json()
print('Receipt queued:', receipt['id'], receipt['status'])

PHP

 true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: YOUR_API_KEY',
        'Content-Type: application/json'
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'payment_type' => 3,
        'client_operation_id' => 'pos-cash-0042',
        'received_amt' => 5000,
        'cart_items' => [
            ['catalog_item_id' => 1, 'quantity' => 2, 'price' => 1800],
            ['catalog_item_id' => 5, 'quantity' => 1]
        ]
    ]),
    CURLOPT_RETURNTRANSFER => true
]);

$response = json_decode(curl_exec($ch), true);
// 202 → $response['id'], $response['status'] === 'pending'
curl_close($ch);

cURL

# Асинхронно выбивает чек в Kaspi OFD. Позиции — из каталога по catalog_item_id
# (только фискальные, с НТИН). client_operation_id — ключ идемпотентности.
# received_amt — наличные, полученные от клиента (для сдачи).
curl -X POST https://api.apipay.kz/api/v1/receipts \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_type": 3,
    "client_operation_id": "pos-cash-0042",
    "received_amt": 5000,
    "cart_items": [
      { "catalog_item_id": 1, "quantity": 2, "price": 1800 },
      { "catalog_item_id": 5, "quantity": 1 }
    ]
  }'
# 202 → { "id": 4210, "status": "pending", "client_operation_id": "pos-cash-0042" }
# Итог узнайте через GET https://api.apipay.kz/api/v1/receipts/4210 или вебхук receipt.issued/receipt.failed.

Статус фискального чека

JavaScript / Node.js

const res = await fetch('https://api.apipay.kz/api/v1/receipts/4210', {
  headers: { 'X-API-Key': 'YOUR_API_KEY' }
})
const receipt = await res.json()
if (receipt.status === 'issued') {
  console.log('Receipt link:', receipt.link, 'FPD:', receipt.fpd)
} else if (receipt.status === 'failed') {
  console.log('Failed:', receipt.error_code)
}

Python

import requests

res = requests.get(
    'https://api.apipay.kz/api/v1/receipts/4210',
    headers={'X-API-Key': 'YOUR_API_KEY'}
)
receipt = res.json()
if receipt['status'] == 'issued':
    print('Receipt link:', receipt['link'], 'FPD:', receipt['fpd'])
elif receipt['status'] == 'failed':
    print('Failed:', receipt['error_code'])

PHP

 ['X-API-Key: YOUR_API_KEY'],
    CURLOPT_RETURNTRANSFER => true
]);

$receipt = json_decode(curl_exec($ch), true);
// $receipt['status']: 'pending' | 'issued' | 'failed'
curl_close($ch);

cURL

# Поллинг статуса чека: pending | issued | failed.
curl "https://api.apipay.kz/api/v1/receipts/4210" \
  -H "X-API-Key: YOUR_API_KEY"
# issued → { "status": "issued", "fpd": "000000000000",
#   "operation_id": "KKM00000000", "link": "https://receipt.kaspi.kz/...",
#   "shift_number": 106 }

История фискальных чеков

JavaScript / Node.js

// Чеки за календарный день мерчанта (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-статусы (общие для всех эндпоинтов)

КодОписание
400Bad Request — некорректный запрос или недопустимое состояние. Точная причина — в поле message или error
401Unauthorized — API-ключ отсутствует, неверен, истёк или не привязан к организации; либо аккаунт деактивирован
403Forbidden — организация заморожена (suspended) или не верифицирована для рабочего режима
tariff_inactive (403)Нет действующей подписки на ApiPay — оплатите тариф в кабинете. Закрывает все платные операции: создание, отмену и возврат счёта, чеки, изменяющий каталог, создание, изменение и возобновление подписок, проверку номера клиента. Грейса нет — блокировка сразу после expires_at (это поле приходит в теле ответа; null, если тариф не оформлялся). Чтение (GET), оплата тарифа, настройки, подключение кассира, а также приостановка и отмена подписок продолжают работать. В песочнице тариф не требуется
404Not Found — ресурс не найден или принадлежит другой организации
422Validation Error — ошибка валидации полей; детали в объекте errors
429Too Many Requests — превышен общий лимит Public API (200 запросов/мин на API-ключ); смотрите заголовок Retry-After. Отдельно: POST /clients/check ограничен 60 запросами/мин и 10 000 запросами/сутки на API-ключ, а POST /invoices/qr — 60 QR/мин на организацию (на этом 429 заголовка Retry-After нет)
500Server Error — внутренняя ошибка сервера
502Bad Gateway — ошибка на стороне Kaspi API
503Service 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_throttledKaspi ограничил частоту запросов. Повторите через 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
error_code: whatsapp_otp_throttledСлишком частые запросы кода WhatsApp — попробуйте позже. Доставка: sync HTTP 429
error_code: whatsapp_gateway_errorНе удалось отправить код WhatsApp — попробуйте ещё раз. Доставка: sync HTTP 500
error_code: subscription_payment_failedНе удалось создать счёт для оплаты подписки — попробуйте позже. Доставка: sync HTTP 502
error_code: kaspi_errorKaspi 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Зарезервирован — сейчас не используется бэкендом. Доставка: —

Каталог: резолв штрихкода (POST /catalog/scan)

КодОписание
kaspi_session_expired (400)Сессия Kaspi мерчанта истекла — нужна переавторизация кассира Kaspi. Доставка: sync HTTP 400
kaspi_throttled (429)Kaspi троттлит сессию при сканировании. Повторить после паузы (тело retry_after_seconds, заголовок Retry-After). Circuit-breaker: ~90 с сразу отдаёт 429 без обращения к Kaspi. Доставка: sync HTTP 429. Примечание: отличается от async error_code kaspi_throttled по счетам
kaspi_scan_unavailable (503)Нацкаталог Kaspi временно недоступен — повторить позже. Доставка: sync HTTP 503

Каталог: приём позиций (POST /catalog)

КодОписание
error_code: catalog_item_invalidПозиция не прошла валидацию: имя, цена, единица измерения или другое поле. Конкретика — в error_message и карте errors по позиции. Доставка: per-item в rejected[] ответа POST /catalog
error_code: catalog_item_duplicateKaspi отклонил товар как похожий на уже существующий в вашем каталоге. Проверьте, нет ли позиции с тем же названием и штрихкодом, — если товар действительно новый, измените название так, чтобы оно отличалось
error_code: barcode_too_longШтрихкод длиннее допустимого — не более 32 символов. Обрежьте значение или передайте позицию без штрихкода
catalog_run_total_conflict (422)Для этого sync_token уже заявлен другой run_total. Размер прогона объявляется один раз, в начале заливки. Начали другой прогон — возьмите новый sync_token

Каталог: массовое удаление (POST /catalog/bulk-delete)

КодОписание
catalog_delete_scope_required (422)Не задан ни один способ выбора позиций либо задано несколько сразу. Передайте ровно один: 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 в разборе позиций

Подключение кассира (POST /connections/{connection}/auth/*)

КодОписание
org_transferred (409)Организация этого кассира уже есть в 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_errorKaspi отклонил выбивание чека; подробности — в поле error_message чека. Повтор возможен с НОВЫМ client_operation_id. В песочнице воспроизводится через simulate. Доставка: async, status=failed
error_code: receipt_dispatch_errorНе удалось отправить чек в Kaspi (сеть или временный сбой). Повторяемая — попробуйте ещё раз с новым client_operation_id. Доставка: async, status=failed
error_code: receipt_ofd_token_revokedФискальная привязка кассы отозвана — мерчанту нужно перепривязать ОФД в приложении Kaspi. Приём оплат при этом работает: счета и QR продолжают создаваться, встают только чек и изменяющий каталог. Не путайте с kaspi_session_invalid — платёжная сессия жива, переподключение кассира не поможет. Повтор имеет смысл только ПОСЛЕ перепривязки ОФД, временем не лечится. Доставка: async, status=failed

Чек Kaspi по счёту (GET /invoices/{id}/receipt)

КодОписание
error_code: receipt_not_available_for_status (409)Чек есть только у оплаченного или частично возвращённого счёта. Проверьте status счёта перед запросом. Тот же код приходит, если у оплаченного счёта ещё нет числового идентификатора Kaspi — чек по такому счёту получить нельзя. Не повторяемая. Доставка: sync HTTP 409
error_code: kaspi_session_expired (409)Кассир, через которого прошла оплата, требует переподключения — чек получить нельзя, пока кассир не подключён заново. Счёт и оплата не затронуты. Доставка: sync HTTP 409
error_code: kaspi_session_unavailable (409)Кассир по счёту сейчас недоступен — повторите позже; если повторяется, проверьте подключение кассира в кабинете. Доставка: sync HTTP 409
error_code: receipt_rate_limited (429)Запросов чеков слишком много — у ручки отдельный лимит. Повторите через минуту. Доставка: sync HTTP 429
error_code: receipt_unavailable (503)Чек получить не удалось. Повторяемая — попробуйте через минуту. Счёт и оплата не затронуты. Доставка: sync HTTP 503

Возврат по QR (клиент подтверждает сканированием)

КодОписание
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)Текущее значение на кассе проверить не удалось — переключение не выполнено. Повторите позже.

Формат ответа ошибки

Ошибка валидации (HTTP 422):

{
  "message": "Описание ошибки",
  "errors": {
    "field_name": ["детали ошибки"]
  }
}

Остальные ошибки (400, 401, 403, 409, 429 и т.д.):

{
  "error": "kaspi_session_not_configured",
  "error_code": "kaspi_session_not_configured",
  "message": "Описание ошибки"
}

Стройте логику по error_code — поле error дублирует его для обратной совместимости. Текст message не парсите: он может локализоваться и меняться. У ошибок валидации error_code нет — их отличает пара message + errors.


Changelog