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 поддержки
  5. Настройте webhook в личном кабинете для получения уведомлений об оплате

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

ПараметрЗначение
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}Получить счёт
5POST/invoices/{id}/cancelОтменить счёт
6POST/invoices/status/checkМассовая проверка статусов
Other
7POST/clients/checkПроверить номер в Kaspi
Возвраты (Refunds)
8POST/invoices/{id}/refundСоздать возврат
9GET/invoices/{id}/refundsВозвраты по счёту
10GET/refundsСписок возвратов
Каталог (Catalog)
11GET/catalog/unitsЕдиницы измерения
12GET/catalogСписок товаров каталога
13POST/catalog/upload-imageЗагрузить изображение товара
14POST/catalogСоздать товары каталога
15PATCH/catalog/{id}Обновить товар каталога
16DELETE/catalog/{id}Удалить товар каталога
17POST/catalog/scanПоиск товара в Нацкаталоге по штрихкоду
Подписки (Subscriptions)
18POST/subscriptionsСоздать подписку
19GET/subscriptionsСписок подписок
20GET/subscriptions/{id}Получить подписку
21PUT/subscriptions/{id}Обновить подписку
22POST/subscriptions/{id}/pauseПриостановить подписку
23POST/subscriptions/{id}/resumeВозобновить подписку
24POST/subscriptions/{id}/cancelОтменить подписку
25GET/subscriptions/{id}/invoicesСчета подписки
Счета (Invoices)
26POST/invoices/{invoice}/simulate-statusСимулировать статус счёта
Other
27GET/webhook-logsЛоги доставки вебхуков
28GET/webhook-logs/{id}Одна доставка вебхука
Health
29GET/statusHealth-check
Счета (Invoices)
30POST/invoices/bulkПакетное создание счетов
31GET/invoices/statsСтатистика счетов
Other
32GET/tariffСтатус своей подписки на ApiPay
33GET/tariff/plansКаталог тарифов
34GET/account/healthHealth своего аккаунта
35GET/connectionsСписок кассиров
36POST/connectionsСоздать кассира
37PUT/connections/{connection}Переименовать кассира
38DELETE/connections/{connection}Деактивировать кассира
39POST/connections/{connection}/primaryНазначить кассира основным
40POST/connections/{connection}/auth/initСтарт авторизации кассира
41POST/connections/{connection}/auth/send-phoneОтправить телефон кассира (SMS)
42POST/connections/{connection}/auth/verify-otpПодтвердить OTP кассира
43GET/connections/{connection}/auth/statusСтатус сессии кассира
Подписки (Subscriptions)
44POST/subscriptions/{subscription}/simulate-invoiceСоздать sandbox-счёт подписки
45POST/subscriptions/{subscription}/start-simulationЗапустить авто-симуляцию подписки
46POST/subscriptions/{subscription}/stop-simulationОстановить авто-симуляцию подписки
Каталог (Catalog)
47GET/catalog/webhook-logsЛоги доставок catalog.item_processed
48GET/catalog/queueОстаток очереди приёма каталога + ETA
49GET/catalog/errorsОшибки приёма каталога
50GET/catalog/batches/{id}Прогресс bulk-батча приёма каталога
Other
51POST/receipts/previewПревью фискального чека
52POST/receiptsВыбить фискальный чек
53GET/receipts/{id}Статус фискального чека
54GET/receiptsИстория фискальных чеков

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

Параметры запроса (query)

ПолеТипОбяз.Описание
status[]string[]Фильтр по статусам (можно несколько значений).
date_fromstringДата от (Y-m-d).
date_tostringДата до (Y-m-d, должна быть >= date_from).
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`.

GET /invoices/{id}

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

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.

Поля ответа

ПолеТипОбяз.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 счетов для проверки. Cap на количество в коде не enforced.

Поля ответа

ПолеТипОбяз.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`.

Параметры запроса (query)

ПолеТипОбяз.Описание
status[]string[]Фильтр по статусам возврата.
invoice_idintegerФильтр по ID счёта (должен существовать).
date_fromstring
date_tostring
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` (усечения `limit(200)` больше нет). Режимы 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).
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

Загружает изображение (jpg/png/gif/webp, макс 10 МБ). Дедупликация по MD5. Полученный image_id передаётся в create/update товара.

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

ПолеТипОбяз.Описание
imagestringДаФайл изображения, макс 10240 KB (10 МБ).

Поля ответа

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

PATCH /catalog/{id}

Обновляет товар. Все поля опциональны — применяются только присланные (`filled`). `external_ref` при обновлении **не принимается**. ⚠️ `ntin`/`gtin` затирают идентичность Нацкаталога безвозвратно (её нельзя восстановить синком) — отправляйте только при реальном изменении. Sandbox → `200`, Production → `202`.

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

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

Поля ответа

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

DELETE /catalog/{id}

Sandbox → 200 (hard delete). Production → 202 (статус → deleting, удаление в Kaspi джобом).

Поля ответа

ПолеТипОбяз.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`.

Поля ответа

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

GET /subscriptions/{id}

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

PUT /subscriptions/{id}

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

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

ПолеТипОбяз.Описание
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: NotConfirmedByUser / CancelledByUser). Создание нового QR на той же кассе старый счёт НЕ отменяет — supersede-вебхука больше нет.
invoice.status_changedexpiredСчёт истёк. Phone-счёт — 24 часа в Kaspi. QR-счёт — минуты, и только когда Kaspi отдал терминальный статус (QrTokenDiscarded), а НЕ по локальному таймеру 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 должен спокойно его игнорировать.

Примеры 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).

Переходы статусов

Гарантия: ровно один вебхук на реальный переход статуса. Дубли подряд одного статуса, технические processing/cancelling, «протухший» pending после терминального статуса и error после paid — подавляются. При этом повторная доставка одного и того же перехода возможна (ретраи после частичной доставки) — дедуплицируйте по (invoice.id, status) и (refund.id, status). Отвечайте 200 OK быстро (≤5 секунд), обрабатывайте асинхронно. Событие invoice.qr_scanned статус НЕ меняет (status остаётся pending) — это суб-состояние, идёт мимо этой матрицы. По каждому QR-счёту в итоге всегда придёт ровно один терминальный вебхук (paid/expired/cancelled) — гарантия держится даже при рестарте воркера или потере real-time цепочки мониторинга.

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

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

Без вебхука

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

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

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

Примеры кода

Симуляция статуса (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 708 516 74 89)
kaspi_session_invalid (503)Сессия кассира Kaspi истекла или сброшена. Переподключите кассира — запросите новый SMS-код
connection_ambiguous (422)У организации несколько активных касс, основная не выбрана — передайте kaspi_connection_id
sandbox_invoice_limit (400)Достигнут лимит тестовых счетов (500 на организацию) — очистите песочницу в кабинете
duplicate_idempotency_key (409)Идемпотентность: активный счёт с таким external_order_id_idempotency уже существует — повторный POST /invoices с тем же ключом не создаёт дубликат (в ответе invoice_id и status существующего счёта). Перевыставление возможно, только если предыдущий счёт с этим external_order_id_idempotency находится в статусе expired, cancelled или error

Поле «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}

Поле «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-код ошибки из фиксированного каталога (31 значение). Присутствует в 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
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: 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

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

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

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

Changelog