Полная документация ApiPay REST API v2 — Автоматизация Kaspi Pay (Phone Payments)
REST API для приёма платежей по номеру телефона через Kaspi Pay. Чеки через Kaspi ОФД, webhooks, поддержка каталога, подписок и возвратов. Без скрытых комиссий.
Подключаете через ИИ-ассистента?
Начните с пошагового плейбука интеграции — apipay.kz/for-ai:
весь сценарий (подключение кассира, ключи, код, вебхуки, отладка) в одном документе.
Быстрый старт
Песочница доступна сразу! При регистрации создаётся sandbox-организация для тестирования API. Вы можете создавать тестовые счета (is_sandbox=true) до подключения Kaspi Pay. Переключение в production — через личный кабинет.
Для работы с реальными платежами подключите кассира Kaspi: самостоятельно в личном кабинете (Настройки → Авторизация Kaspi, около 1 минуты) или через WhatsApp поддержки
Настройте webhook в личном кабинете для получения уведомлений об оплате
Базовая конфигурация
Параметр
Значение
Base URL
https://api.apipay.kz/api/v1
Аутентификация
Header X-API-Key: ваш_api_ключ
Rate Limit
200 req/min на API-ключ (общий лимит Public API; при превышении 429 + Retry-After)
Content-Type
application/json
Обзор эндпоинтов
#
Метод
Путь
Описание
Счета (Invoices)
1
POST
/invoices
Создать счёт
2
POST
/invoices/qr
Создать QR-счёт
3
GET
/invoices
Список счетов
4
GET
/invoices/{id}
Получить счёт
5
POST
/invoices/{id}/cancel
Отменить счёт
6
POST
/invoices/status/check
Массовая проверка статусов
Other
7
POST
/clients/check
Проверить номер в Kaspi
Возвраты (Refunds)
8
POST
/invoices/{id}/refund
Создать возврат
9
GET
/invoices/{id}/refunds
Возвраты по счёту
10
GET
/refunds
Список возвратов
Каталог (Catalog)
11
GET
/catalog/units
Единицы измерения
12
GET
/catalog
Список товаров каталога
13
POST
/catalog/upload-image
Загрузить изображение товара
14
POST
/catalog
Создать товары каталога
15
PATCH
/catalog/{id}
Обновить товар каталога
16
DELETE
/catalog/{id}
Удалить товар каталога
17
POST
/catalog/scan
Поиск товара в Нацкаталоге по штрихкоду
Подписки (Subscriptions)
18
POST
/subscriptions
Создать подписку
19
GET
/subscriptions
Список подписок
20
GET
/subscriptions/{id}
Получить подписку
21
PUT
/subscriptions/{id}
Обновить подписку
22
POST
/subscriptions/{id}/pause
Приостановить подписку
23
POST
/subscriptions/{id}/resume
Возобновить подписку
24
POST
/subscriptions/{id}/cancel
Отменить подписку
25
GET
/subscriptions/{id}/invoices
Счета подписки
Счета (Invoices)
26
POST
/invoices/{invoice}/simulate-status
Симулировать статус счёта
Other
27
GET
/webhook-logs
Логи доставки вебхуков
28
GET
/webhook-logs/{id}
Одна доставка вебхука
Health
29
GET
/status
Health-check
Счета (Invoices)
30
POST
/invoices/bulk
Пакетное создание счетов
31
GET
/invoices/stats
Статистика счетов
Other
32
GET
/tariff
Статус своей подписки на ApiPay
33
GET
/tariff/plans
Каталог тарифов
34
GET
/account/health
Health своего аккаунта
35
GET
/connections
Список кассиров
36
POST
/connections
Создать кассира
37
PUT
/connections/{connection}
Переименовать кассира
38
DELETE
/connections/{connection}
Деактивировать кассира
39
POST
/connections/{connection}/primary
Назначить кассира основным
40
POST
/connections/{connection}/auth/init
Старт авторизации кассира
41
POST
/connections/{connection}/auth/send-phone
Отправить телефон кассира (SMS)
42
POST
/connections/{connection}/auth/verify-otp
Подтвердить OTP кассира
43
GET
/connections/{connection}/auth/status
Статус сессии кассира
Подписки (Subscriptions)
44
POST
/subscriptions/{subscription}/simulate-invoice
Создать sandbox-счёт подписки
45
POST
/subscriptions/{subscription}/start-simulation
Запустить авто-симуляцию подписки
46
POST
/subscriptions/{subscription}/stop-simulation
Остановить авто-симуляцию подписки
Каталог (Catalog)
47
GET
/catalog/webhook-logs
Логи доставок catalog.item_processed
48
GET
/catalog/queue
Остаток очереди приёма каталога + ETA
49
GET
/catalog/errors
Ошибки приёма каталога
50
GET
/catalog/batches/{id}
Прогресс bulk-батча приёма каталога
Other
51
POST
/receipts/preview
Превью фискального чека
52
POST
/receipts
Выбить фискальный чек
53
GET
/receipts/{id}
Статус фискального чека
54
GET
/receipts
История фискальных чеков
Health Check
GET /status
Проверка доступности API. **Без авторизации** (`X-API-Key` не требуется).
Подпадает под общий гостевой лимит `60 запросов/мин на IP`.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
status
string
—
—
timestamp
string
—
—
Текущее время сервера, ISO 8601 UTC (+00:00).
Счета (Invoices)
GET /invoices
Пагинированный список счетов организации с фильтрацией и сортировкой.
Пагинация — **плоская** `{current_page, data, total}` (не meta-обёртка).
Даты — UTC `+00:00`.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
search
string
—
Поиск по описанию, телефону, external_order_id.
status[]
string[]
—
Фильтр по статусам (можно несколько значений).
date_from
string
—
Дата от (Y-m-d).
date_to
string
—
Дата до (Y-m-d, должна быть >= date_from).
sort_by
string
—
Поле сортировки (невалидное значение → created_at).
sort_order
string
—
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
current_page
integer
—
—
data
any[]
—
—
total
integer
—
—
POST /invoices
Создаёт счёт на оплату по номеру телефона. Обработка **асинхронная**: ответ `201`
приходит со `status: "processing"` (Kaspi ещё не вызван), финальный статус
(`pending`/`error`) — вебхуком `invoice.status_changed` или поллингом
`GET /invoices/{id}`. Не пересоздавайте счёт, пока он в `processing`.
Два режима суммы: без корзины (`amount`) или с корзиной (`cart_items`, сумма
считается сервером). Организации с каталогом обязаны присылать `cart_items`.
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
Описание
message
string
—
—
invoice
any
—
—
POST /invoices/{id}/refund
Полный или частичный возврат по оплаченному счёту. Без `amount` — полный возврат.
Ответ `201` = возврат принят и поставлен в очередь (`status: pending`). Итог —
вебхук `invoice.refunded` (`completed`/`failed`, причина в `refund.error_code`).
Для счетов с корзиной можно указать `return_items` — на позицию **ровно одно** из
полей `count` (целые штуки) ИЛИ `amount` (произвольная сумма); оба или ни одного → `422`.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
refund
any
—
—
invoice
object
—
—
invoice.id
integer
—
—
invoice.amount
string
—
—
invoice.total_refunded
string
—
—
invoice.available_for_refund
number
—
—
Сумма, доступная для возврата (число, не строка).
invoice.pending_refund_amount
number
—
—
Сумма ожидающих возвратов (число, не строка).
GET /invoices/{id}/refunds
Список возвратов конкретного счёта с агрегатами по счёту. Даты — UTC +00:00.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
invoice
object
—
—
invoice.id
integer
—
—
invoice.amount
string
—
—
invoice.total_refunded
string
—
—
invoice.available_for_refund
number
—
—
invoice.is_fully_refunded
boolean
—
—
refunds
any[]
—
—
total
integer
—
—
POST /invoices/status/check
Возвращает актуальные статусы нескольких счетов организации за один запрос.
`invoice_ids.*` не проверяется на существование (анти-энумерация) — несуществующие
и чужие ID молча не попадут в ответ. Скоуп — организация ключа.
Параметры запроса
Поле
Тип
Обяз.
Описание
invoice_ids
integer[]
Да
ID счетов для проверки. Cap на количество в коде не enforced.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
invoices
object[]
—
—
invoices.id
integer
—
—
invoices.status
string
—
—
invoices.kaspi_invoice_id
string
—
да
invoices.amount
string
—
—
invoices.error_message
string
—
да
invoices.updated_at
string
—
—
Возвраты (Refunds)
GET /refunds
Все возвраты организации (по всем счетам) с фильтрацией. Пагинация — **плоская**
`{current_page, data, total}`. Даты — UTC `+00:00`.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
status[]
string[]
—
Фильтр по статусам возврата.
invoice_id
integer
—
Фильтр по ID счёта (должен существовать).
date_from
string
—
date_to
string
—
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
current_page
integer
—
—
data
any[]
—
—
total
integer
—
—
Каталог (Catalog)
GET /catalog/units
Список единиц измерения для товаров. Получите перед созданием товаров, чтобы использовать корректные unit_id.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
object[]
—
—
data.id
integer
—
—
data.name
string
—
—
data.name_kaz
string
—
—
GET /catalog
Товары каталога. **Четыре режима чтения** (по приоритету):
1. **targeted** — `ntins[]`/`barcodes[]`/`ids[]`/`external_refs[]` (OR, все статусы,
суммарно ≤200 значений по 4 наборам) → форма `{data:[...]}` без пагинации
(точечное подтверждение батча);
2. **incremental** — `updated_after` → инкрементальный экспорт изменённого;
3. **keyset** — `cursor` (курсорная пагинация без deep-offset, для экспорта 100k+);
4. **default (offset)** — постранично `page`/`per_page`.
**Статусы (default = active):** без параметра `statuses[]` ЛЮБОЙ режим отдаёт
только `active`. Чтобы получить другие статусы (`pending`/`failed`/`deleting`/
`deleted`) — передайте `statuses[]` явно (в т.ч. в incremental для зеркалирования
удалений: `?updated_after=…&statuses[]=active&statuses[]=deleted`).
**Призраки НЕ отдаются никогда:** строки `status='deleted'` с `kaspi_item_id=null`
(позиции, отклонённые до отправки в Kaspi — их никогда не было в Kaspi) исключаются
во всех режимах, даже при явном `?statuses[]=deleted`. Настоящие удаления
(`deleted` с непустым `kaspi_item_id`) доступны через `?statuses[]=deleted`.
**Лимиты targeted (явная ошибка вместо тихого усечения):** суммарное число значений
по `ntins[]`+`barcodes[]`+`ids[]`+`external_refs[]` ≤200, и число найденных СТРОК
соответствий ≤1000. Превышение любого → `422` с `error_code: catalog_match_overflow`
(усечения `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_after
string
—
Incremental: только изменённое после даты (вкл. удалённые).
cursor
string
—
Keyset: курсор пагинации (из meta.next_cursor).
search
string
—
Поиск по названию.
barcode
string
—
Точный фильтр по штрихкоду.
first_char
string
—
Фильтр по первой букве названия.
without_ntin
boolean
—
`true` → только позиции без НТИН (`ntin` = null), независимо от наличия штрихкода — шире, чем поле ответа `ntin_missing` (то требует непустой `barcode`). Удобно считать «сколько осталось доделать» по `meta.total`. Компонуется со всеми режимами и фильтрами (`statuses[]`, `search` и т.д.).
batch_id
string
—
Фильтр по UUID батча bulk-приёма (last_batch_id). Компонуется со всеми режимами.
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
links
any
—
—
meta
any
—
—
POST /catalog
Создаёт 1–100 товаров пакетно. Sandbox → `201` (товар сразу активирован);
Production → `202` (товар в статусе `pending`, синхронизация в Kaspi джобом).
`external_ref` — клиентская ссылка (например код 1С) для последующего точечного
чтения (`?external_refs[]=`).
**Валидация per-item lenient.** Каждая позиция валидируется отдельно: валидные
обрабатываются штатно (`data[]`), невалидные попадают в `rejected[]` (см. ниже) и
НЕ роняют весь запрос. Одна битая позиция больше не даёт `422` на весь батч. `422`
остаётся ТОЛЬКО за структурными ошибками ЗАПРОСА: `items` отсутствует / не массив /
пуст / больше лимита (100). Ключ `rejected[]` присутствует в ответе ВСЕГДА (пустой
`[]`, когда все позиции валидны). Если ВСЕ позиции невалидны — ответ всё равно
успешный (`202`/`201`) с пустым `data[]` и полным `rejected[]`.
**Match-and-merge работает и в sandbox** (тот же матчинг и маркеры), но синхронно и
без обращения к Kaspi: matched-товар возвращается сразу активным, переиздание
удалённого по `external_ref` активируется мгновенно (без фазы `pending`).
**Match-and-merge (идемпотентная заливка).** Если позиция совпадает с уже
существующим товаром, вместо дубля/ошибки возвращается ЖИВОЙ id существующего
товара с маркером `matched_existing: true` (id стабилен). Ярусы матча (по
приоритету): (1) совпал `external_ref` → полный update имени+цены + дозаполнение
пустых `ntin`/`gtin`/`barcode`; (2) совпал `barcode` ИЛИ `ntin` И совпало имя →
update цены + дозаполнение пустых; (3) совпал `barcode` ИЛИ `ntin`, но имя другое
(например разные размеры под одним штрихкодом — Kaspi считает это одним товаром) →
матч БЕЗ перезаписи имени/цены, маркер `name_differs: true` (правьте имя явным
`PATCH /catalog/{id}`). `external_ref` существующего товара НИКОГДА не
перетирается. Повторная заливка того же каталога идемпотентна: все позиции
вернутся `matched_existing: true`, новые строки не создаются. Дозаполненные поля
уезжают в Kaspi асинхронно. **Используйте `external_ref` как ключ маппинга** (не
barcode/имя): на один barcode у мерчанта может быть несколько товаров.
**Переиздание удалённого / статусы матча по `external_ref`:** если `external_ref`
указывает на удалённый (`deleted`) товар — он автоматически переиздаётся: ТА ЖЕ
строка (тот же `id`) возвращается в `pending` с полями из запроса и заново
отправляется в Kaspi (`matched_existing: true`, `status: pending`). Если
`external_ref` указывает на `failed`-товар — возвращается его текущая строка со
`status: failed` БЕЗ автоматического повтора: исправьте данные и переотправьте
позицию через `PATCH /catalog/{id}` (для строки без `kaspi_item_id` PATCH сам
переотправляет создание) либо кнопкой «Повторить» в кабинете.
Параметры запроса
Поле
Тип
Обяз.
Описание
idempotency_key
string
—
Ключ идемпотентности (эквивалент заголовка Idempotency-Key). Повтор → тот же батч (200).
items
object[]
Да
items.name
string
Да
items.selling_price
number
Да
items.unit_id
integer
Да
items.image_id
string
—
ID изображения из upload-image (exists).
items.barcode
string
—
items.ntin
string
—
НТИН кандидата Нацкаталога (из POST /catalog/scan).
items.gtin
string
—
GTIN кандидата Нацкаталога (GS1).
items.external_ref
string
—
Клиентская ссылка (1С).
items.from_catalog
boolean
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
rejected
any[]
—
—
batch
any
—
—
POST /catalog/upload-image
Загружает изображение (jpg/png/gif/webp, макс 10 МБ). Дедупликация по MD5. Полученный image_id передаётся в create/update товара.
Параметры запроса
Поле
Тип
Обяз.
Описание
image
string
Да
Файл изображения, макс 10240 KB (10 МБ).
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
image_id
string
—
—
PATCH /catalog/{id}
Обновляет товар. Все поля опциональны — применяются только присланные (`filled`).
`external_ref` при обновлении **не принимается**. ⚠️ `ntin`/`gtin` затирают
идентичность Нацкаталога безвозвратно (её нельзя восстановить синком) — отправляйте
только при реальном изменении. Sandbox → `200`, Production → `202`.
Параметры запроса
Поле
Тип
Обяз.
Описание
name
string
—
selling_price
number
—
unit_id
integer
—
image_id
string
—
Новый ID изображения (exists).
is_image_deleted
boolean
—
true — удалить изображение.
barcode
string
—
ntin
string
—
⚠️ Затирает идентичность Нацкаталога — только при реальном изменении.
gtin
string
—
⚠️ То же предупреждение, что и для ntin.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
catalog_item_id
integer
—
—
DELETE /catalog/{id}
Sandbox → 200 (hard delete). Production → 202 (статус → deleting, удаление в Kaspi джобом).
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
catalog_item_id
integer
—
—
Подписки (Subscriptions)
GET /subscriptions
Подписки организации. Пагинация — **meta-обёртка** `{data, links, meta}`
(`meta.current_page`/`total`/`per_page`/`last_page`/`from`/`to`). Фильтры — точное
совпадение (не LIKE). Сортировка фиксированная: `created_at DESC`. Даты — UTC `+00:00`.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
status
string
—
Точный фильтр по статусу.
external_subscriber_id
string
—
phone_number
string
—
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
links
any
—
—
meta
any
—
—
POST /subscriptions
Создаёт рекуррентную подписку. Правила суммы зависят от организации:
**без каталога** — `amount` обязателен (100–1 000 000), `cart_items` запрещён;
**с каталогом** — `cart_items` обязателен (сумма считается сервером), `amount` игнорируется.
`bill_immediately=true` — первый счёт сразу; иначе по расписанию в `next_billing_at`.
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
message
string
—
—
subscription
any
—
—
GET /subscriptions/{id}
Подписка со статистикой (`stats`) и последним платежом (`last_payment`).
PUT /subscriptions/{id}
Все поля опциональны. `cart_items` на не-каталог организации → 422. При передаче cart_items сумма пересчитывается.
Возобновляет paused-подписку (next_billing_at пересчитывается от текущего момента, пропущенные периоды не доначисляются).
POST /subscriptions/{id}/cancel
Отменяет active/paused-подписку безвозвратно (реактивации нет — создавайте новую).
GET /subscriptions/{id}/invoices
Счета, созданные подпиской. **Нестандартный конверт** `{data, meta}` — `meta`
содержит `current_page`/`total`/`per_page` (без `last_page`). Даты — UTC `+00:00`.
Параметры запроса (query)
Поле
Тип
Обяз.
Описание
per_page
integer
—
page
integer
—
Поля ответа
Поле
Тип
Обяз.
Nullable
Описание
data
any[]
—
—
meta
object
—
—
meta.current_page
integer
—
—
meta.total
integer
—
—
meta.per_page
integer
—
—
Webhooks
Webhooks настраиваются через личный кабинет ApiPay.kz (Настройки > Подключение). При создании webhook вы получите secret для верификации подписи (HMAC-SHA256).
События
invoice.status_changed
invoice.qr_scanned
invoice.refunded
receipt.issued
receipt.failed
subscription.payment_succeeded
subscription.payment_failed
subscription.grace_period_started
subscription.expired
subscription.created
subscription.paused
subscription.resumed
subscription.cancelled
webhook.test
Когда приходит вебхук
Этот раздел перечисляет события, при которых ApiPay шлёт вебхук, и поясняет, в каком статусе он приходит. Технические статусы processing/cancelling вебхуков не порождают.
Событие
Статус
Когда
invoice.status_changed
pending
Счёт создан в Kaspi и ожидает оплату. Для счетов по номеру (POST /invoices) это первый вебхук после 201-ответа со status=processing. Для QR-счетов (POST /invoices/qr) pending-вебхук НЕ отправляется — статус возвращается синхронно в 201-ответе; первый вебхук по QR-счёту — оплата, отмена, истечение или ошибка.
invoice.qr_scanned
pending
Только для QR-счетов: клиент отсканировал QR и оказался на экране оплаты Kaspi (qr_substate=scanned). status остаётся pending — это суб-состояние, а не смена статуса. Шлётся ровно один раз; событие транзиентно — далее придёт paid (оплатил) или cancelled (свернул/закрыл приложение). Не считайте скан гарантией оплаты.
invoice.status_changed
paid
Счёт оплачен. Может прийти и ПОСЛЕ cancelled/expired — оплата в последний момент выигрывает гонку (см. «Переходы статусов»).
invoice.status_changed
cancelled
Счёт отменён: вами через API, кассиром, либо для QR — реальной отменой клиентом (свернул/закрыл приложение Kaspi: NotConfirmedByUser / CancelledByUser). Создание нового QR на той же кассе старый счёт НЕ отменяет — supersede-вебхука больше нет.
invoice.status_changed
expired
Счёт истёк. Phone-счёт — 24 часа в Kaspi. QR-счёт — минуты, и только когда Kaspi отдал терминальный статус (QrTokenDiscarded), а НЕ по локальному таймеру qr_expires_at.
invoice.status_changed
error
Техническая ошибка — счёт финализирован, система больше НЕ повторяет попытки по этому счёту. Причина — в error_code/error_message. Что делать — раздел «Сценарии реагирования».
invoice.status_changed
partially_refunded
Первый частичный возврат по счёту (дополнительно к invoice.refunded). Повторные частичные возвраты статус не меняют. Полный возврат статус НЕ меняет — счёт остаётся paid (или partially_refunded, если ранее был частичный) с is_fully_refunded=true.
invoice.refunded
completed
Возврат проведён. Включает возвраты, сделанные кассиром в приложении Kaspi (импортируются автоматически).
invoice.refunded
failed
Возврат не удался (refund.error_code). Система сама НЕ повторяет; сумма не блокируется — можно создать новый возврат.
receipt.issued
—
Фискальный чек выбит в Kaspi OFD (POST /receipts, наличные / POS другого банка). В receipt — fpd, operation_id, link, shift_number. Равноправно поллингу GET /receipts/{id}. Гейт вебхуков receipt.* отдельный (по умолчанию выключен).
receipt.failed
—
Не удалось выбить фискальный чек (POST /receipts). Причина — receipt.error_code (shift_closed, item_not_fiscal, rfo_missing, receipt_kaspi_error, receipt_dispatch_error). Фискальный документ НЕ создан — повторите с НОВЫМ client_operation_id.
subscription.created
—
Подписка создана через POST /subscriptions. Счета по подписке выставляет система автоматически: первый счёт будет выставлен в next_billing_at (или сразу при bill_immediately). По каждому счёту приходят обычные invoice-вебхуки.
subscription.payment_succeeded
—
Очередной счёт подписки оплачен. failed_attempts сброшен, льготный период (если был) снят.
subscription.payment_failed
—
Счёт подписки истёк или отменён (reason). Пока attempt_number < max_retry_attempts (по умолчанию 3) система САМА перевыставит счёт с интервалом retry_interval_hours (по умолчанию 24 ч) — ничего пересоздавать не нужно, просто уведомите клиента (attempt_number, reason). Счёт со статусом error провалом НЕ считается (это событие не придёт) — отслеживайте invoice-вебхук error.
subscription.grace_period_started
—
Все попытки исчерпаны; подписка ещё активна grace_period_days (по умолчанию 3) дней. Любая успешная оплата снимает льготный период.
subscription.expired
—
Льготный период истёк — биллинг остановлен навсегда, реактивации нет. Для возобновления создайте новую подписку.
subscription.paused
—
Подписка приостановлена (POST /subscriptions/{id}/pause). Счета не выставляются.
subscription.resumed
—
Подписка возобновлена; next_billing_at пересчитан от момента возобновления, пропущенные периоды не доначисляются.
subscription.cancelled
—
Подписка отменена безвозвратно (next_billing_at сохраняет последнее значение, счета не выставляются).
webhook.test
—
Ручной тест из ЛК (Настройки → API-ключи → Тест вебхука). Фиктивный счёт со status=test — receiver должен спокойно его игнорировать.
Ваш внешний идентификатор заказа, переданный при создании счёта.
invoice.amount
string
—
Сумма счёта.
invoice.subtotal
string
да
Сумма до применения скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе).
invoice.discount_sum
string
да
Сумма скидки. Только для счетов с корзиной/скидкой (subtotal и discount_sum приходят вместе).
invoice.discount_percentage
string
да
Процент скидки. Только для счетов с корзиной/скидкой.
invoice.status
string
—
Статус счёта: pending / paid / cancelled / expired / error / partially_refunded. Статуса refunded не существует — полный возврат оставляет paid + is_fully_refunded=true.
invoice.kaspi_invoice_id
string
да
ID счёта в Kaspi. Появляется уже при pending (когда счёт создан в Kaspi); null — пока счёт не дошёл до Kaspi.
invoice.client_phone
string
—
Номер телефона клиента.
invoice.kaspi_source_type
string
да
Источник средств клиента: GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее».
invoice.kaspi_sale_type
string
да
Способ приёма счёта: Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR. Обычно присутствует при status=paid, но гейтится по наличию значения, а не строго по статусу: поле приходит, когда Kaspi вернул значение (например, счёт, уже получивший его, может пробросить поле и в статусах cancelled/expired), и отсутствует/null иначе. Может отсутствовать для старых счетов; список может расширяться — обрабатывайте неизвестные значения как «прочее».
invoice.paid_at
string
да
Время оплаты счёта (ISO 8601). Поле отсутствует во всех статусах, кроме paid (а не null до оплаты).
invoice.error_message
string
да
Человекочитаемая причина. При status=error присутствует всегда; при status=cancelled — только если заполнена (обычно отсутствует при отмене клиентом или через API). В статусах paid/pending/expired поле отсутствует.
invoice.error_code
string
да
Стабильный snake_case-код из каталога (раздел "Коды ошибок"). Присутствует только если не null и только при status=error/cancelled. Стройте switch-логику по нему, а не по тексту.
invoice.cancelled_at
string
да
Время перехода в cancelled (ISO 8601). Присутствует только при соответствующем статусе.
invoice.expired_at
string
да
Время перехода в expired (ISO 8601). Присутствует только при соответствующем статусе.
invoice.errored_at
string
да
Время перехода в error (ISO 8601). Присутствует только при соответствующем статусе.
pending / processing / completed / failed. Вебхук приходит на completed И на failed.
refund.kaspi_refund_id
string
да
ID возврата в Kaspi; null при неудаче.
refund.reason
string
да
Причина возврата.
refund.created_at
string
—
Время создания возврата (ISO 8601).
refund.error_code
string
да
Только при status=failed. Например refund_window_expired — истёк срок возврата (~14 дней). Поля error_message в вебхуке нет by design — текст смотрите в GET /invoices/{id}/refunds или резолвите код по каталогу.
refund.items
array
да
Позиции возврата (только для позиционных возвратов): catalog_item_id, name, price, count, amount.
invoice.id
integer
—
Внутренний ID счёта в ApiPay.
invoice.external_order_id
string
да
Ваш внешний идентификатор заказа.
invoice.amount
string
—
Сумма счёта.
invoice.subtotal
string
—
Сумма счёта до применения скидки.
invoice.discount_sum
string
—
Сумма скидки по счёту.
invoice.total_refunded
string
—
Суммарно возвращено по счёту на текущий момент.
invoice.available_for_refund
number
—
Сумма, ещё доступная для возврата. Приходит числом (float), в отличие от amount и total_refunded, которые передаются строками.
invoice.is_fully_refunded
boolean
—
true, если счёт возвращён полностью.
invoice.is_sandbox
boolean
—
Счёт создан в sandbox-режиме.
invoice.status
string
—
Статус счёта после возврата. Полный возврат статус НЕ меняет (остаётся paid — или partially_refunded, если ранее был частичный) + is_fully_refunded=true; первый частичный переводит в partially_refunded (и дополнительно приходит invoice.status_changed).
Ваш ключ идемпотентности, переданный при выбивании чека.
receipt.payment_type
integer
—
Тип оплаты: 3 — наличные, 5 — POS другого банка.
receipt.status
string
—
Статус чека — failed.
receipt.fpd
string
да
При failed всегда null — фискальный документ не создан.
receipt.operation_id
string
да
При failed всегда null.
receipt.link
string
да
При failed всегда null.
receipt.shift_number
integer
да
При failed обычно null.
receipt.total_price
string
да
Сумма чека.
receipt.error_code
string
да
Код причины: shift_closed (закрыта смена), item_not_fiscal (позиция без НТИН), rfo_missing, receipt_kaspi_error, receipt_dispatch_error. Стройте switch по нему, не по тексту.
receipt.error_message
string
да
Человекочитаемое пояснение ошибки.
timestamp
string
—
Время отправки события (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 цепочки мониторинга.
cancelled | expired → paid — оплата в последний момент выигрывает гонку — обработайте как «деньги получены»: отгрузите или сделайте возврат. Это не баг.
error → pending — реконсиляция: счёт на самом деле успел создаться в Kaspi — следуйте последнему статусу
paid → partially_refunded — первый частичный возврат
Никогда не происходят
терминальный → pending — кроме error → pending (реконсиляция)
paid → error — подавляется как инцидент
error → paid — невозможен
Без вебхука
202-ответ на отмену переводит счёт в cancelling БЕЗ вебхука; если Kaspi отказал в отмене (обычно счёт уже оплачен) — счёт тихо возвращается в pending, реальный статус (обычно paid) доставит синхронизация в течение минут. После 202 не считайте счёт отменённым — ждите вебхук.
Сценарии реагирования
Когда вебхук приносит status=error (счёт) или status=failed (возврат) — операция финализирована: система уже исчерпала собственные ретраи и больше НЕ будет повторять её сама. «Повторить» всегда означает «создать новую операцию». Пока счёт в processing — система ретраит сама, вмешиваться не нужно (легитимный бэклог может держать счёт в processing больше часа при троттлинге Kaspi).
Ошибка
Что произошло
Что делает система
Что делать вам
client_not_found
Номер телефона не зарегистрирован в Kaspi
Финализирует счёт сразу, без ретраев
Запросите у клиента другой номер и создайте новый счёт
network_unavailable
Сеть/Kaspi были недоступны
Ретраила сама; вебхук означает, что ретраи исчерпаны
Создайте новый счёт/возврат через 1–2 минуты
session_transient
Временный сбой сессии кассира
Автоматически инвалидировала сессию и ретраила
Создайте новый счёт позже; если повторяется — переподключите кассира в ЛК
kaspi_throttled
Kaspi ограничил частоту запросов кассы
Автоматически замедляет очередь этой кассы (интервал до 3 минут на счёт) и ретраит; вебхук = финализация
Пока счёт в processing — ничего. После error — новый счёт через 2–3 минуты; снизьте темп создания счетов
organization_not_configured
К организации не подключён кассир Kaspi
Финализирует сразу
Подключите кассира: ЛК → Настройки → Авторизация Kaspi
invoice_already_paid
Попытка отменить уже оплаченный счёт
Отмену остановила; деньги получены
Не отменяйте; если нужно вернуть деньги — создайте возврат
invoice_already_cancelled
Счёт уже отменён
—
Ничего: желаемое состояние уже достигнуто
invoice_not_found_in_kaspi
Kaspi не нашёл счёт при отмене
Финализирует error
Обратитесь в поддержку
refund_window_expired
Истёк срок возврата (~14 дней) или возврат уже сделан
Возврат failed, ретраев нет
Не повторяйте; сообщите клиенту или обратитесь в поддержку
qr_render_failed
Не сформировалось изображение QR
Счёт финализирован в error (и 500-ответ, и вебхук)
Повторите POST /invoices/qr — создастся новый счёт
kaspi_session_invalid
Сессия кассира истекла в момент создания QR
Счёт финализирован в error; сессия инвалидирована
Повторите позже; если повторяется — переподключите кассира
kaspi_error
Неклассифицированная ошибка Kaspi
Зависит от причины; для QR — счёт error + вебхук
Читайте message/error_message; повторите или обратитесь в поддержку
unknown_error
Непредвиденная ошибка (в т.ч. исчерпаны все попытки создания)
Финализировала после всех ретраев
Создайте новый счёт; если повторяется — поддержка
Без error_code
Новый QR на той же кассе (QR сосуществуют) — Создание нового QR на той же кассе НЕ отменяет прежние — старый QR остаётся pending и мониторится до своего терминала; supersede-вебхука cancelled больше нет. Два параллельных POST /invoices/qr оба получают 201 + pending (409 superseded — defensive-ветка, на практике недостижима). Реагируйте на paid/cancelled/expired по каждому invoice.id ОТДЕЛЬНО: если клиент оплатит оба QR — придут два paid.
Клиент отменил QR (cancelled) — Вебхук cancelled по QR = реальная отмена клиентом (свернул/закрыл приложение Kaspi: NotConfirmedByUser / CancelledByUser). Необратима — оплатить этот QR больше нельзя (ссылка одноразовая). Если отмена пришла после qr_scanned — откатите состояние «ожидается оплата» и при необходимости предложите новый QR.
Счёт истёк — Вебхук expired. Phone-счёт — 24 часа. QR — минуты, и только когда Kaspi отдал терминал (не по локальному таймеру). При необходимости создайте новый счёт.
Оплата после отмены/истечения — Вебхук paid после cancelled/expired: деньги получены — отгрузите или сделайте возврат.
Возврат к pending после error — Корректирующий pending-вебхук (реконсиляция). Следуйте последнему статусу.
Retry Policy
Subscription webhooks — До 11 попыток: первая доставка + 10 повторов при неуспехе. Интервалы нарастают: 10с, 30с, 1м, 1.5м, 2м, 5м, 10м, 15м, 30м, 1ч (всего ~2 часа)
Invoice webhooks — До 11 попыток: первая доставка + 10 повторов при неуспехе. Интервалы нарастают: 10с, 30с, 1м, 1.5м, 2м, 5м, 10м, 15м, 30м, 1ч (всего ~2 часа)
Timeout: 5 секунд на ответ (плюс до 3 секунд на установление соединения)
HTTP 2xx = success
Sandbox: В sandbox-режиме invoice-вебхуки доставляются всего за 3 попытки (интервалы 5с, 15с). Вебхуки refund и subscription всегда используют полные 11 попыток — sandbox-сокращения для них нет.
3xx/4xx: Ретраится только HTTP ≥500, ровно 429 и сетевые ошибки. Ответы 3xx и 4xx (кроме 429) НЕ ретраятся — попытка сразу фиксируется как доставленная. Повторить такую доставку можно только вручную: ЛК → Webhook-логи → Retry (доступно для записей со status=failed, cooldown между ручными повторами — 10 секунд).
Дедупликация: Дедупликация на стороне клиента обязательна: ретрай после частичной доставки двум получателям пере-отправляет вебхук всем. Ключи дедупликации: (invoice.id, invoice.status) для invoice-событий, (refund.id, refund.status) для возвратов, (event, subscription.id, invoice_id) для событий подписки. Отвечайте 200 OK быстро (≤5 секунд), обрабатывайте асинхронно.
Наблюдаемость подписок: События subscription.* не пишутся в Webhook-логи ЛК: для них нет ручного retry и circuit breaker (известное ограничение).
UTC: Все даты в вебхуках — ISO 8601 в UTC (+00:00).
Circuit breaker
Если ваш endpoint стабильно недоступен, отправка на ключ приостанавливается: 5 подряд неудач → пауза 5 минут, 10 → 30 минут, 20 → 2 часа, 50 → полное отключение до ручного вмешательства. Вебхуки за время паузы НЕ доотправляются — сверяйте состояние через GET-методы. Любая успешная доставка (или успешный тест-вебхук из ЛК) сбрасывает счётчик. Статус виден в списке API-ключей.
// Только для sandbox-счёта (is_sandbox: true) в статусе pending.
const res = await fetch('https://api.apipay.kz/api/v1/invoices/42/simulate-status', {
method: 'POST',
headers: {
'X-API-Key': 'YOUR_SANDBOX_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'paid' })
// status: 'error' -> error_code: sandbox_simulated_error (+ optional error_message)
// status: 'qr_scanned' -> только для QR-счёта, статус остаётся pending
})
const data = await res.json()
console.log('Simulated:', data.invoice.status)
Python
import requests
# Только для sandbox-счёта (is_sandbox: true) в статусе pending.
res = requests.post(
'https://api.apipay.kz/api/v1/invoices/42/simulate-status',
headers={'X-API-Key': 'YOUR_SANDBOX_API_KEY', 'Content-Type': 'application/json'},
json={'status': 'paid'}
)
print('Simulated:', res.json()['invoice']['status'])
cURL
# Только для sandbox-счёта (is_sandbox: true) в статусе pending.
curl -X POST https://api.apipay.kz/api/v1/invoices/42/simulate-status \
-H "X-API-Key: YOUR_SANDBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "paid" }'
Проверка доставки вебхука
JavaScript / Node.js
// Убедиться, что по счёту 42 ушёл вебхук и приёмник ответил 2xx.
const res = await fetch(
'https://api.apipay.kz/api/v1/webhook-logs?invoice_id=42&event=invoice.status_changed',
{ headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
const delivered = data.some(l => l.status === 'success')
console.log('Webhook delivered:', delivered)
Python
import requests
# Убедиться, что по счёту 42 ушёл вебхук и приёмник ответил 2xx.
res = requests.get(
'https://api.apipay.kz/api/v1/webhook-logs',
headers={'X-API-Key': 'YOUR_API_KEY'},
params={'invoice_id': 42, 'event': 'invoice.status_changed'}
)
logs = res.json()['data']
print('Webhook delivered:', any(l['status'] == 'success' for l in logs))
cURL
# Убедиться, что по счёту 42 ушёл вебхук и приёмник ответил 2xx.
curl "https://api.apipay.kz/api/v1/webhook-logs?invoice_id=42&event=invoice.status_changed" \
-H "X-API-Key: YOUR_API_KEY"
Проверка доставки вебхука каталога
JavaScript / Node.js
// Проверить, что позиция каталога 12345 обработана и вебхук ушёл.
const res = await fetch(
'https://api.apipay.kz/api/v1/catalog/webhook-logs?catalog_item_id=12345&status=success',
{ headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
console.log('Deliveries:', data.length)
Python
import requests
# Проверить, что позиция каталога 12345 обработана и вебхук ушёл.
res = requests.get(
'https://api.apipay.kz/api/v1/catalog/webhook-logs',
headers={'X-API-Key': 'YOUR_API_KEY'},
params={'catalog_item_id': 12345, 'status': 'success'}
)
logs = res.json()['data']
print('Deliveries:', len(logs))
cURL
# Проверить, что позиция каталога 12345 обработана и вебхук ушёл.
curl "https://api.apipay.kz/api/v1/catalog/webhook-logs?catalog_item_id=12345&status=success" \
-H "X-API-Key: YOUR_API_KEY"
Идемпотентная bulk-заливка каталога (Idempotency-Key + батч)
import requests
# Повтор с тем же Idempotency-Key вернёт существующий батч (200) без пересоздания.
res = requests.post(
'https://api.apipay.kz/api/v1/catalog',
headers={
'X-API-Key': 'YOUR_API_KEY',
'Idempotency-Key': 'import-2026-07-11-0001',
},
json={
'items': [
{'name': 'Фильтр масляный', 'selling_price': 1800, 'unit_id': 1, 'external_ref': '1C-000123'}
]
},
)
batch = res.json().get('batch') or {}
print('Batch:', batch.get('batch_id'), batch.get('poll_url'))
cURL
# Повтор с тем же Idempotency-Key вернёт СУЩЕСТВУЮЩИЙ батч (HTTP 200) без пересоздания.
curl -X POST https://api.apipay.kz/api/v1/catalog \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: import-2026-07-11-0001" \
-d '{
"items": [
{ "name": "Фильтр масляный", "selling_price": 1800, "unit_id": 1, "external_ref": "1C-000123" }
]
}'
# В ответе (202/200) — блок batch с batch_id и poll_url.
# Прогресс: GET https://api.apipay.kz/api/v1/catalog/batches/8f14e45f-ceea-467e-9a2c-0000000000aa
# Фильтр списка по батчу: GET https://api.apipay.kz/api/v1/catalog?batch_id=8f14e45f-ceea-467e-9a2c-0000000000aa
Остаток очереди приёма каталога + ETA
JavaScript / Node.js
// Остаток pending-очереди приёма каталога с блоком queue (ETA в минутах).
const res = await fetch('https://api.apipay.kz/api/v1/catalog/queue?sort_order=asc&per_page=100', {
headers: { 'X-API-Key': 'YOUR_API_KEY' }
})
const { total, queue } = await res.json()
console.log('Remaining:', total, 'ETA min:', queue?.eta_minutes, 'state:', queue?.state)
Python
import requests
# Остаток pending-очереди приёма каталога с блоком queue (ETA в минутах).
res = requests.get(
'https://api.apipay.kz/api/v1/catalog/queue',
headers={'X-API-Key': 'YOUR_API_KEY'},
params={'sort_order': 'asc', 'per_page': 100},
)
body = res.json()
print('Remaining:', body['total'], 'ETA min:', body['queue'].get('eta_minutes'))
cURL
# Остаток pending-очереди приёма каталога со слим-полями и блоком queue (ETA в минутах).
curl "https://api.apipay.kz/api/v1/catalog/queue?sort_order=asc&per_page=100" \
-H "X-API-Key: YOUR_API_KEY"
# Ответ: { current_page, data:[{id, external_ref, name, queued_at}], total, queue:{ state, eta_minutes, ... } }
Журнал ошибок приёма каталога
JavaScript / Node.js
// Failed-позиции по конкретному батчу (обезличенные тексты ошибок).
const res = await fetch(
'https://api.apipay.kz/api/v1/catalog/errors?batch_id=8f14e45f-ceea-467e-9a2c-0000000000aa&per_page=100',
{ headers: { 'X-API-Key': 'YOUR_API_KEY' } }
)
const { data } = await res.json()
data.forEach((e) => console.log(e.external_ref, e.error_code, e.error_message))
Python
import requests
# Failed-позиции по конкретному батчу (обезличенные тексты ошибок).
res = requests.get(
'https://api.apipay.kz/api/v1/catalog/errors',
headers={'X-API-Key': 'YOUR_API_KEY'},
params={'batch_id': '8f14e45f-ceea-467e-9a2c-0000000000aa', 'per_page': 100},
)
for e in res.json()['data']:
print(e['external_ref'], e['error_code'], e['error_message'])
cURL
# Failed-позиции по конкретному батчу (обезличенные тексты ошибок).
curl "https://api.apipay.kz/api/v1/catalog/errors?batch_id=8f14e45f-ceea-467e-9a2c-0000000000aa&per_page=100" \
-H "X-API-Key: YOUR_API_KEY"
# Без batch_id и from — окно последних 7 дней по created_at.
// Чеки за календарный день мерчанта (Asia/Almaty): голая дата включает весь день.
const params = new URLSearchParams({
from: '2026-07-14',
to: '2026-07-14',
status: 'issued',
per_page: '20',
})
const res = await fetch(`https://api.apipay.kz/api/v1/receipts?${params}`, {
headers: { 'X-API-Key': 'YOUR_API_KEY' }
})
const { data, total } = await res.json()
data.forEach((r) => console.log(r.id, r.status, r.total_price, r.link))
console.log('total:', total)
Python
import requests
# Чеки за календарный день мерчанта (Asia/Almaty): голая дата включает весь день.
res = requests.get(
'https://api.apipay.kz/api/v1/receipts',
headers={'X-API-Key': 'YOUR_API_KEY'},
params={'from': '2026-07-14', 'to': '2026-07-14', 'status': 'issued', 'per_page': 20},
)
body = res.json()
for r in body['data']:
print(r['id'], r['status'], r['total_price'], r['link'])
print('total:', body['total'])
cURL
# Чеки за календарный день мерчанта (Asia/Almaty): голая дата включает весь день.
curl "https://api.apipay.kz/api/v1/receipts?from=2026-07-14&to=2026-07-14&status=issued&per_page=20" \
-H "X-API-Key: YOUR_API_KEY"
# Плоская пагинация: { "current_page": 1, "data": [...], "total": 12 }.
# Чтение истории не гейтится kill-switch'ем — список доступен всегда.
Коды ошибок
Ошибки приходят в нескольких формах — не путайте их между собой:
HTTP-статус (400, 401, 429 …) — общий класс ошибки, присутствует всегда.
Поле error в теле ответа — конкретная причина синхронной ошибки. Историческая особенность бэкенда: часть значений — машинные коды в snake_case (organization_required, kaspi_session_not_configured), а часть — английские фразы целиком (Organization not found or not verified, Invoice cannot be cancelled). Поэтому форматы и различаются. Не сравнивайте текст error в коде.
Поле error_message — человекочитаемый текст асинхронной ошибки Kaspi (счёт создан со статусом processing и позже перешёл в error). Фиксированных кодов у Kaspi нет.
Поле error_code (новое) — стабильный snake_case-код из фиксированного каталога. Стройте switch-логику по нему, а не по тексту error/error_message.
В колонке «Код» ниже значения сгруппированы по тому, где именно они появляются.
HTTP-статусы (общие для всех эндпоинтов)
Код
Описание
400
Bad Request — некорректный запрос или недопустимое состояние. Точная причина — в поле message или error
401
Unauthorized — API-ключ отсутствует, неверен, истёк или не привязан к организации; либо аккаунт деактивирован
403
Forbidden — организация заморожена (suspended) или не верифицирована для рабочего режима
tariff_inactive (403)
Нет действующей подписки на ApiPay — оплатите тариф в кабинете. Закрывает все платные операции: создание, отмену и возврат счёта, чеки, изменяющий каталог, создание, изменение и возобновление подписок, проверку номера клиента. Грейса нет — блокировка сразу после expires_at (это поле приходит в теле ответа; null, если тариф не оформлялся). Чтение (GET), оплата тарифа, настройки, подключение кассира, а также приостановка и отмена подписок продолжают работать. В песочнице тариф не требуется
404
Not Found — ресурс не найден или принадлежит другой организации
422
Validation Error — ошибка валидации полей; детали в объекте errors
429
Too Many Requests — превышен общий лимит Public API (200 запросов/мин на API-ключ); смотрите заголовок Retry-After. Отдельно: POST /clients/check ограничен 60 запросами/мин и 10 000 запросами/сутки на API-ключ, а POST /invoices/qr — 60 QR/мин на организацию (на этом 429 заголовка Retry-After нет)
500
Server Error — внутренняя ошибка сервера
502
Bad Gateway — ошибка на стороне Kaspi API
503
Service Unavailable — сессия Kaspi недействительна или истекла
Поле «error» при создании счёта (POST /invoices, /invoices/qr)
Код
Описание
organization_required (400)
Организация не подключена — создайте sandbox-организацию для тестов или подключите кассира Kaspi
Organization not found or not verified (400)
Рабочий режим: организация не верифицирована. Дождитесь верификации или тестируйте в песочнице
kaspi_session_not_configured (400)
Кассир Kaspi не подключён. Подключите его в кабинете (Настройки → Авторизация Kaspi) или через поддержку (WhatsApp +7 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_throttled
Kaspi ограничил частоту запросов. Повторите через 2–3 минуты. Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: refund_window_expired
Срок возврата истёк или возврат уже сделан (часто для refund status=failed). Доставка: async — приходит в invoice.refunded (refund.error_code)
error_code: invoice_already_paid
Счёт уже оплачен. Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: invoice_already_cancelled
Счёт уже отменён. Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: invoice_not_found_in_kaspi
Счёт не найден в Kaspi. Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: organization_not_configured
Организация не настроена (нет рабочей привязки Kaspi). Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: unknown_error
Непредвиденная ошибка обработки — обратитесь в поддержку. Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: qr_render_failed
Не удалось сформировать изображение QR-кода — повторите запрос. Доставка: sync HTTP 500 + async (webhook invoice.status_changed, status=error)
error_code: kaspi_session_invalid
Сессия кассира Kaspi истекла или сброшена — переподключите кассира. Доставка: sync HTTP 503 + async (webhook invoice.status_changed, status=error)
error_code: kaspi_session_unavailable
Не удалось проверить сессию Kaspi — попробуйте позже. Доставка: sync HTTP 503
error_code: manager_throttled
Слишком много операций — попробуйте позже. Доставка: sync HTTP 429
Не удалось отправить код WhatsApp — попробуйте ещё раз. Доставка: sync HTTP 500
error_code: subscription_payment_failed
Не удалось создать счёт для оплаты подписки — попробуйте позже. Доставка: sync HTTP 502
error_code: kaspi_error
Kaspi API вернул ошибку — повторите позже. Текст message содержит конкретную причину от Kaspi. Доставка: sync HTTP 502 + async (для QR-счетов: webhook invoice.status_changed, status=error)
error_code: trial_daily_limit
Антифрод/триал: на пробном тарифе превышен дневной лимит создания счетов (50 счетов/сутки). Дождитесь следующего дня или оформите подписку. Доставка: sync HTTP 429 + заголовок Retry-After
error_code: outstanding_recipient_limit
Антифрод: слишком много неоплаченных (outstanding) счетов на одного получателя. Дождитесь оплаты или истечения ранее выставленных счетов. Доставка: sync HTTP 429 + заголовок Retry-After
error_code: outstanding_org_limit
Антифрод: слишком много неоплаченных (outstanding) счетов по организации в целом. Дождитесь оплаты или истечения ранее выставленных счетов. Доставка: sync HTTP 429 + заголовок Retry-After
error_code: recipient_fanout_exceeded
Антифрод: превышен темп рассылки счетов по разным получателям (fan-out). Снизьте частоту создания счетов на разные номера. Доставка: sync HTTP 429 + заголовок Retry-After
error_code: content_rejected
Антифрод: содержимое счёта (например текст описания) отклонено проверкой. Исправьте текст и повторите. Доставка: sync HTTP 422
error_code: sandbox_simulated_error
Симулированная ошибка в песочнице: POST /invoices/{id}/simulate-status со status=error присваивает счёту этот error_code (с опциональным error_message до 255 символов) и шлёт вебхук invoice.status_changed как при реальной ошибке. Только sandbox (в проде simulate-status недоступен). Доставка: async — приходит в invoice.status_changed (invoice.error_code)
error_code: cart_items_mismatch
Зарезервирован — сейчас не используется бэкендом (такие случаи приходят как kaspi_error). Доставка: —
error_code: image_upload_failed
Зарезервирован — сейчас не используется бэкендом. Доставка: —
error_code: catalog_item_not_found
Зарезервирован — сейчас не используется бэкендом. Доставка: —
Kaspi троттлит сессию при сканировании. Повторить после паузы (тело retry_after_seconds, заголовок Retry-After). Circuit-breaker: ~90 с сразу отдаёт 429 без обращения к Kaspi. Доставка: sync HTTP 429. Примечание: отличается от async error_code kaspi_throttled по счетам
kaspi_scan_unavailable (503)
Нацкаталог Kaspi временно недоступен — повторить позже. Доставка: sync HTTP 503
KYC: верификация бизнеса и webhook-домен (новое)
Код
Описание
error_code: kyc_daily_limit_reached
Молодая организация: пока анкета о бизнесе не одобрена, доступен 1 реальный счёт в сутки (окно Asia/Almaty; счета в песочнице не считаются). Чтобы снять лимит — заполните короткую анкету «Расскажите о бизнесе» в кабинете (/business-profile), одобрение обычно за 1 рабочий день. Не повторяйте запрос до сброса. Доставка: sync HTTP 429 (meta.reset_at — когда лимит сбросится, meta.kyc_status — текущий статус)
error_code: kyc_rejected
Приём платежей недоступен по итогам проверки бизнеса (статус организации blocked). Не повторяемая — напишите в поддержку, если считаете это ошибкой. Доставка: sync HTTP 403 при создании счёта (POST /invoices, POST /invoices/qr)
error_code: webhook_url_requires_domain
Адрес webhook должен быть на вашем домене — IP-адреса не принимаются. Действует для ещё не одобренных организаций в рабочем режиме; в песочнице правило мягче. Укажите публичный HTTPS-адрес на домене. Доставка: sync HTTP 422 при сохранении webhook-URL
error_code: webhook_url_tunnel_forbidden
Туннели (ngrok и подобные) нельзя использовать для рабочих webhook — они временны и отключатся, уведомления перестанут приходить. Укажите адрес на вашем домене. Действует для ещё не одобренных организаций в рабочем режиме; в песочнице туннель для локального теста допустим. Доставка: sync HTTP 422 при сохранении webhook-URL
Фискальные чеки (Kaspi OFD)
Код
Описание
error_code: fiscal_receipts_disabled (403)
Выбивание чеков отключено для боевых организаций (фича включается постепенно). В песочнице чеки работают всегда — обкатайте интеграцию там. Не повторяемая. Доставка: sync HTTP 403
error_code: not_sandbox (403)
Поле simulate прислано боевой организацией — форсировать исход чека можно только в песочнице. Чек не создан. Уберите simulate из тела запроса. Доставка: sync HTTP 403 (POST /receipts)
error_code: kaspi_session_not_configured (409)
К организации не привязан кассир Kaspi Pay — выбить чек не через кого. Подключите кассира в кабинете (Настройки → Подключение кассира). Доставка: sync HTTP 409
error_code: duplicate_client_operation_id (409)
Чек с таким client_operation_id уже выбивался — второй раз он не пробьётся (идемпотентность). В теле ответа приходит receipt_id существующего чека: опросите его через GET /receipts/{id}. Повтор после failed разрешён, но с НОВЫМ ключом. Доставка: sync HTTP 409
error_code: connection_ambiguous (422)
К организации привязано несколько кассиров — непонятно, через какого выбивать чек. Передайте kaspi_connection_id явно. Доставка: sync HTTP 422
error_code: receipt_preview_unavailable
Предпросмотр чека временно недоступен (POST /receipts/preview). Повторяемая — попробуйте позже; на выбивание самого чека не влияет. Доставка: sync HTTP 503
error_code: receipt_not_found (404)
Чек не найден или принадлежит другой организации. Доставка: sync HTTP 404 (GET /receipts/{id})
error_code: shift_closed
Смена на кассе закрыта — чек выбить нельзя. Откройте смену в приложении Kaspi Pos и повторите с новым client_operation_id. В песочнице воспроизводится через simulate. Доставка: async, status=failed (GET /receipts/{id}, вебхук receipt.failed)
error_code: item_not_fiscal
В чеке есть позиция, не зарегистрированная фискально: у товара должны быть и штрихкод (barcode), и НТИН Нацкаталога (ntin). Дозаполните НТИН в каталоге (PATCH /catalog/{id}) — позиция станет фискальной. Позиции без НТИН удобно найти через GET /catalog?without_ntin=true. Правило одинаково в бою и в песочнице. Доставка: async, status=failed
error_code: rfo_missing
У кассира не настроен фискальный регистратор (РФО) — Kaspi не может зарегистрировать чек. Проверьте настройки кассы в Kaspi Pos. Доставка: async, status=failed
error_code: receipt_kaspi_error
Kaspi отклонил выбивание чека; подробности — в поле error_message чека. Повтор возможен с НОВЫМ client_operation_id. В песочнице воспроизводится через simulate. Доставка: async, status=failed
error_code: receipt_dispatch_error
Не удалось отправить чек в Kaspi (сеть или временный сбой). Повторяемая — попробуйте ещё раз с новым client_operation_id. Доставка: async, status=failed
2026-07-22 [changed] Ошибка 403 tariff_inactive (нет действующей подписки на ApiPay) теперь закрывает ВСЕ платные операции, а не только создание счёта и чека: отмену и возврат счёта, весь изменяющий каталог (создание, изменение, удаление, повтор, scan, sync, загрузка изображения), создание, изменение и возобновление подписок, POST /organizations/{id}/sync и проверку номера клиента. Грейс-период отменён — блокировка наступает сразу после expires_at, а не через 3 дня. В теле 403 добавлено поле expires_at (ISO 8601) — когда истёк тариф; null, если тариф не оформлялся ни разу. Продолжают работать: все операции чтения (GET), оплата тарифа, управление ключами, менеджерами и настройками организации, подключение и переподключение кассира, проверка статусов счетов, а также приостановка и отмена подписок. Отдельно: POST /invoices/bulk при неактивном тарифе отбивает весь запрос 403, а не возвращает 201 с tariff_inactive в поэлементном invoices[]. Песочница и тестовые организации не блокируются вовсе — тариф там не требуется.
2026-07-14 [new] Появился GET /receipts — история фискальных чеков организации: пагинированный список (свежие сверху), плоская пагинация {current_page, data, total}, элемент списка той же формы, что отдаёт GET /receipts/{id}. Фильтры: status (pending | issued | failed), payment_type (3 — наличные, 5 — POS другого банка), invoice_id, окно дат from / to. Чтение истории НЕ гейтится kill-switch'ем fiscal_receipts_disabled (он про выбивание чека): даже с выключенной фичей список уже выбитых чеков остаётся доступен. Выборка скоупится режимом организации — боевая организация не видит чеки песочницы, и наоборот. per_page — от 1 до 100 (по умолчанию 20). Окно дат from / to трактуется в Asia/Almaty (+05:00): голая дата (2026-07-14) — это календарные сутки мерчанта, а to включает весь день целиком; дата-время с явным смещением берётся как есть. Компенсировать смещение на клиенте не нужно. То же правило окна дат теперь действует и у GET /catalog/errors. Изменение аддитивное.
2026-07-13 [changed] Песочница фискальных чеков теперь зеркалит рабочий режим и доступна независимо от постепенного включения фичи в бою: kill-switch (403 fiscal_receipts_disabled) гейтит только боевые организации, в песочнице чеки работают всегда. Позиция каталога фискальна, только если у неё есть И штрихкод (barcode), И НТИН Нацкаталога (ntin): такая позиция даёт issued с реальными суммами, а позиция без НТИН — failed / item_not_fiscal, ровно как в бою (раньше sandbox-чек молча выходил пустым на 0 ₸). В POST /receipts добавлено поле simulate — только для sandbox-организаций: {"simulate": {"status": "failed", "error_code": "shift_closed"}} форсирует исход чека, чтобы обкатать обработку ошибок (error_code — shift_closed | item_not_fiscal | receipt_kaspi_error, по умолчанию receipt_kaspi_error; status — issued | failed). На боевой организации simulate возвращает 403 not_sandbox, чек не создаётся. Вебхуки receipt.issued / receipt.failed в песочнице уходят независимо от прод-флага вебхуков — доставку можно проверить через GET /webhook-logs?event=receipt.failed. Изменение аддитивное.
2026-07-12 [changed] Базовый URL API изменён на https://api.apipay.kz/api/v1 (партнёрский — https://api.apipay.kz/api/partner). Обновите базовый адрес в своих интеграциях.
2026-07-12 [new] Новая группа эндпоинтов «Фискальные чеки» (Kaspi OFD) для оплат, НЕ прошедших через Kaspi QR — наличными (payment_type=3) и через POS другого банка (payment_type=5): POST /receipts/preview (синхронное превью строк чека для UI), POST /receipts (асинхронно выбивает чек) и GET /receipts/{id} (статус и реквизиты — fpd, operation_id, link, shift_number). Модель асинхронная: чек создаётся в статусе pending и переходит в issued или failed — итог узнавайте поллингом GET /receipts/{id} либо вебхуком receipt.issued / receipt.failed. Идемпотентность по client_operation_id (уникален на организацию): повтор с тем же ключом не выбивает второй чек (409 duplicate_client_operation_id); повтор после failed разрешён с НОВЫМ ключом. Позиции — из синхронизированного каталога по catalog_item_id, только фискально зарегистрированные (с НТИН), иначе item_not_fiscal. Фича за kill-switch: при отключении отдаётся 403 fiscal_receipts_disabled; включается постепенно. Изменение аддитивное.
2026-07-12 [new] GET /catalog: добавлен фильтр without_ntin. При without_ntin=true возвращаются только позиции без НТИН (ntin = null), независимо от наличия штрихкода — шире, чем поле ответа ntin_missing (оно требует непустой barcode). Удобно считать «сколько осталось доделать» по meta.total. Компонуется со всеми режимами и фильтрами (statuses[], search и т.д.). Изменение аддитивное.
2026-07-11 [new] Новый эндпоинт GET /catalog/queue — остаток pending-очереди приёма каталога (POST /catalog) со слим-полями плюс блок queue с ETA в минутах. ETA учитывает общую FIFO-очередь кассира. Плоская пагинация {current_page, data, total, queue}; параметры sort_order (по умолчанию asc), per_page (20–100), page. Rate-limit 600/min на ключ (выделенный catalog-poll).
2026-07-11 [new] Новый эндпоинт GET /catalog/errors — журнал ошибок приёма каталога (failed-позиции) с обезличенными текстами ошибок. Фильтр по периоду постановки в очередь (created_at); без from — окно последних 7 дней. Параметры from, to, batch_id, sort_order (по умолчанию desc), per_page (20–100), page. Плоская пагинация {current_page, data, total}. Rate-limit 600/min на ключ.
2026-07-11 [new] Новый эндпоинт GET /catalog/batches/{id} — агрегированный прогресс bulk-батча приёма каталога (totals: total/created/updated/skipped/failed, pending_remaining, poll_url, статус). Скоуп строго по организации ключа: чужой/несуществующий/битый UUID → 404 (non-enumeration). Итог батча также приходит вебхуком catalog.batch_processed. Rate-limit 600/min на ключ.
2026-07-11 [changed] POST /catalog: bulk-приём стал идемпотентным — заголовок Idempotency-Key (или body-поле idempotency_key, ≤191): повтор с тем же ключом возвращает существующий батч (HTTP 200) без пересоздания позиций. В ответы добавлен блок batch (агрегат bulk-батча + poll_url; в 202 — только на инжест-пути). В GET /catalog добавлен фильтр ?batch_id= (по last_batch_id, компонуется со всеми режимами). Новый вебхук catalog.batch_processed — один агрегированный итог bulk-заливки вместо лавины per-item (дедуп по batch_id+status; sample_failed до 50 позиций без текста ошибки).
2026-07-09 [changed] POST /catalog переведён на match-and-merge: совпадение позиции с существующим товаром возвращает его живой id с маркером matched_existing: true (без дублей), маркер name_differs: true — если совпал barcode/НТИН, но имя другое (имя не перезаписано). external_ref — ключ маппинга (UNIQUE per org, не перетирается), повторная заливка идемпотентна. Новый ответ 409 error_code catalog_busy — каталог занят другой операцией, повторите запрос.
2026-07-09 [changed] GET /catalog: по умолчанию во всех режимах отдаётся только статус active — прочие статусы запрашивайте явно через statuses[]. Призраки (deleted без kaspi_item_id) не отдаются никогда. Targeted-режим: суммарно ≤200 значений по всем наборам и ≤1000 строк соответствий, превышение → 422 error_code catalog_match_overflow (тихого усечения limit(200) больше нет).
2026-07-09 [changed] Схема товара каталога (CatalogItem) дополнена полями: ntin_missing (есть barcode, но нет НТИН → не попадёт в фискальный чек как маркированный; присутствует во всех ответах), matched_existing и name_differs (только в ответе POST /catalog). Все изменения аддитивные.
2026-07-07 [new] Многоуровневая верификация бизнеса (tiered KYC). Молодая организация проходит короткую анкету «Расскажите о бизнесе» (кабинет, /business-profile). Пока анкета не одобрена, в рабочем режиме доступен 1 реальный счёт в сутки (окно Asia/Almaty; песочница без ограничений) — при превышении HTTP 429 error_code kyc_daily_limit_reached (meta.reset_at, meta.kyc_status). По итогам проверки организация может быть заблокирована — создание счёта тогда отдаёт HTTP 403 error_code kyc_rejected. Для ещё не одобренных организаций в рабочем режиме адрес webhook должен быть на вашем домене: IP → HTTP 422 webhook_url_requires_domain, туннели (ngrok и подобные) → HTTP 422 webhook_url_tunnel_forbidden (в песочнице правила мягче). Все изменения аддитивные. Зачем это нужно: проверка снижает риск блокировки легитимного продавца антифродом и позволяет настроить лимиты под ваши обороты.
2026-07-06 [new] Документация /docs выровнена с кодом. Добавлены разделы: Quickstart «первый счёт за 5 минут», Rate-лимиты (полная таблица), Идемпотентность и ретраи, QR-счета: жизненный цикл (событие invoice.qr_scanned), Мультикассир (/connections* с гейтом can_manage_cashiers), Тариф и здоровье аккаунта (GET /tariff, /tariff/plans, /account/health), Bulk-счета (POST /invoices/bulk, до 100, лимит 20/мин), Sandbox (магические номера, simulate-*). Каталог error_code дополнен антифрод-кодами (outstanding_recipient_limit, outstanding_org_limit, trial_daily_limit — 429 + Retry-After). Все изменения аддитивные.
2026-07-06 [new] Автономное тестирование для ИИ-агентов: sandbox-агент с API-ключом теперь проходит полный цикл создание счёта → изменение статуса → проверка вебхука без человека. (1) POST /api/v1/invoices/{id}/simulate-status расширен: помимо paid/cancelled/expired добавлены значения error (счёт получает error_code: sandbox_simulated_error и опциональный error_message до 255 символов, уходит вебхук invoice.status_changed как при реальной ошибке) и qr_scanned (только для QR-счетов: статус остаётся pending, уходит вебхук invoice.qr_scanned с qr_substate=scanned, повтор → 400 already_scanned). Симуляция работает ТОЛЬКО в песочнице (is_sandbox=true); в рабочем режиме недоступна ни при каких условиях (боевой счёт → 403 not_sandbox). Переход только из pending (иначе 400 invalid_status_transition). Отдельный лимит 60 запросов/мин на ключ. (2) Новые read-only эндпоинты GET /api/v1/webhook-logs и GET /api/v1/webhook-logs/{id} (X-API-Key) — программная верификация доставки вебхуков: фильтры invoice_id, event, status, date_from/date_to, плоская пагинация {current_page, data, total}; поля event, url, response_status, status, request_body, response_body, response_time_ms, created_at; чужой лог → 404. Retry в v1 нет (только в кабинете). Sandbox-вебхуки: 3 попытки, backoff 5с/15с (в проде до 11 попыток ~2ч), успех = любой 2xx. Изменения аддитивные и обратносовместимые.
2026-07-06 [new] POST /invoices: задокументирован ключ идемпотентности external_order_id_idempotency. Если передать external_order_id_idempotency (до 191 символа), повторный запрос с тем же значением не создаёт дубликат, а возвращает 409 с error `duplicate_idempotency_key` (в теле invoice_id и status существующего счёта). Перевыставить счёт с тем же external_order_id_idempotency можно только когда предыдущий счёт с этим ключом уже в статусе expired, cancelled или error. Это отдельное поле: external_order_id (до 255 символов) остаётся справочным внешним ID заказа (сохраняется в счёте, приходит в вебхуках, участвует в поиске) и ключом идемпотентности не является. Дополнительно в теле POST /invoices задокументирован опциональный kaspi_connection_id (ID кассира) — для организаций с несколькими активными кассами; при отсутствии выбранной primary-кассы и нескольких активных возвращается 422 connection_ambiguous (ранее это было описано только для QR-счетов POST /invoices/qr). Изменения документационные и аддитивные: поведение бэкенда не менялось.
2026-07-06 [new] В каталог error_code добавлены антифрод-коды защиты пользователей Kaspi от злоупотреблений (каталог расширен до 27 значений): trial_daily_limit (на пробном тарифе лимит 50 счетов/сутки), outstanding_recipient_limit и outstanding_org_limit (слишком много неоплаченных счетов на одного получателя / по организации), recipient_fanout_exceeded (превышен темп рассылки счетов по разным получателям, fan-out) — все четыре приходят синхронно как HTTP 429 с заголовком Retry-After; content_rejected — содержимое счёта отклонено проверкой, HTTP 422. Стройте обработку по error_code. Изменение документационное и аддитивное.
2026-07-01 [new] Добавлен новый эндпоинт POST /api/v1/catalog/scan — синхронный резолв штрихкода в Нацкаталоге Kaspi. Возвращает список товаров-кандидатов (id, name, ntin, gtin, barcode, unit_id, image_link) + normalized_barcode + scan_result; один штрихкод может дать несколько кандидатов (общий gtin, разные ntin). Пустой data[] означает, что товар не найден в Нацкаталоге — это НЕ ошибка (HTTP 200). Лимиты: 30 запросов/мин и 2000/сутки на API-ключ; circuit-breaker: при троттлинге Kaspi ~90 секунд сразу отдаётся 429. Ошибки сканирования: 400 kaspi_session_expired (нужна переавторизация кассира Kaspi), 429 kaspi_throttled (тело retry_after_seconds, заголовок Retry-After), 503 kaspi_scan_unavailable. Дополнительно при создании товара (POST /catalog) появились опциональные поля ntin, gtin и from_catalog, а при редактировании (PATCH /catalog/{id}) — опциональные ntin и gtin; в ответах GET и POST /catalog у товара добавилось поле gtin (может быть null). Все изменения аддитивные и обратносовместимые. ВАЖНО: при обычном редактировании (PATCH) НЕ передавайте ntin/gtin — пустое значение (null) затрёт идентичность Нацкаталога в Kaspi и его нельзя восстановить синхронизацией.
2026-06-24 [changed] Поэлементный возврат (POST /invoices/{id}/refund, return_items[]) теперь принимает на позицию РОВНО одно из двух полей: count (целые штуки, как раньше; сумма = price × count) ЛИБО amount (произвольная сумма по позиции, 0.01 … 9 999 999.99, не больше остатка по позиции). Это позволяет вернуть часть денег по неделимой позиции (count=1, например услуга). Указание обоих полей или ни одного — ошибка 422 (Validation failed) с ключами errors.return_items.{i} (оба/ни одного) и errors.return_items.{i}.amount|count (превышение остатка). Изменение аддитивное и обратносовместимое: старый формат с count работает без изменений. В ответе 201 и в вебхуке invoice.refunded у позиции, возвращённой по amount, refund.items[].count = 0, а деньги — в amount.
2026-06-15 [changed] Для QR-счетов (POST /invoices/qr) поле description ограничено 100 символами — это наименование позиции в QR-чеке Kaspi, длина которой ограничена самим Kaspi. При превышении возвращается 422 (Validation failed) с errors.description. На телефонные счета (POST /invoices) ограничение не распространяется — там description по-прежнему до 500 символов. Рекомендация для QR: краткое описание (номер заказа + имя), без длинных названий.
2026-06-14 [new] Новое вебхук-событие invoice.qr_scanned: клиент отсканировал QR-счёт и находится на экране оплаты Kaspi. status остаётся pending, payload содержит маркер qr_substate=scanned. Событие аддитивное, шлётся ровно один раз на QR-счёт и транзиентно — после него штатно приходит invoice.status_changed со status paid (оплатил) или cancelled (свернул/закрыл приложение). HMAC-подпись не менялась.
2026-06-14 [changed] Изменена модель жизненного цикла QR-счетов (POST /invoices/qr). QR-счета теперь СОСУЩЕСТВУЮТ: создание нового QR на той же кассе больше НЕ отменяет прежние — старый QR остаётся pending и мониторится до своего терминала, вебхук cancelled с текстом «Заменён новым QR-счётом #N» больше НЕ приходит. Два параллельных запроса оба получают 201 + pending (409 superseded остался как defensive-ветка, на практике недостижим). status cancelled по QR теперь означает реальную отмену клиентом (NotConfirmedByUser / CancelledByUser), а не системную замену. status expired по QR приходит только когда Kaspi отдал терминал (QrTokenDiscarded) — реагируйте на терминальные вебхуки по каждому invoice.id отдельно (возможны два paid), а не по локальному таймеру qr_expires_at. Жизненный цикл QR — минуты, не 24 часа (phone-счета по-прежнему 24 часа). Гарантия: по каждому QR в итоге придёт ровно один терминальный вебхук даже при рестарте воркера.
2026-06-11 [new] Вебхуки: добавлены уведомления о неудачных возвратах (invoice.refunded со status=failed и refund.error_code), об ошибках QR-счетов (invoice.status_changed со status=error и error_code: kaspi_session_invalid / kaspi_error / qr_render_failed — дублирует синхронный 5xx-ответ) и о первом частичном возврате (invoice.status_changed со status=partially_refunded). Изменения аддитивные, HMAC-подпись не менялась.
2026-06-11 [new] Документация вебхуков актуализирована: добавлены события subscription.created/paused/resumed/cancelled, примеры payload для статусов error/cancelled/failed, разделы «Когда приходит вебхук», «Переходы статусов» (включая легитимные cancelled→paid и error→pending) и «Сценарии реагирования» (что система делает автоматически и что делать интегратору по каждому error_code), описание circuit breaker и ретраев (включая sandbox). Исправлено: даты в вебхуках — UTC; статуса refunded у счёта не существует (полный возврат оставляет paid + is_fully_refunded).
2026-06-01 [new] GET /catalog: добавлен опциональный query-параметр statuses[] — фильтр списка товаров по статусу (можно передать несколько значений: active, pending, deleting, failed). Фильтрация выполняется на сервере до пагинации, поэтому meta.total и last_page отражают отфильтрованную выборку. Изменение аддитивное: без параметра поведение прежнее (возвращаются все видимые товары). Статус deleted в выдачу не попадает by design.
2026-05-29 [changed] Уточнения в документации по итогам сверки с бэкендом: общий лимит Public API — 200 запросов/мин на API-ключ (при 429 — заголовок Retry-After); лимиты 60 запросов/мин и 10 000 запросов/сутки относятся только к POST /clients/check; POST /invoices/qr — отдельный счётчик 60 QR/мин на организацию (на его 429 заголовка Retry-After нет). Лимит тестовых счетов в sandbox — 500 на организацию. В каталоге error_code для каждого кода размечена синхронность доставки: async (приходит в webhook) либо sync (HTTP-ответ с указанным кодом).
2026-05-28 [new] Унификация ошибок: во все JSON-ответы об ошибках и в webhook-объекты invoice (status=error) и refund (status=failed) добавлено новое поле error_code — стабильный snake_case-код из фиксированного каталога (например client_not_found, network_unavailable, kaspi_error, qr_render_failed, kaspi_session_invalid). Изменение аддитивное и обратно совместимое: поля message и error не изменились. Определяйте тип ошибки по error_code, а не по тексту message. error_code в refund-объекте приходит без error_message; HMAC-подпись webhook не менялась.
2026-05-28 [new] Webhook invoice.status_changed теперь может содержать два опциональных поля от Kaspi: kaspi_source_type — источник средств клиента (GOLD — дебетовая карта Kaspi, RED — кредитная карта Kaspi, LOAN — рассрочка/кредит, BUSINESSACCOUNT — бизнес-счёт, BANKINTEGRATIONACCOUNT — привязанный внешний банковский счёт) и kaspi_sale_type — способ приёма счёта (Remote — push на номер, QR — QR-код, Restaurant — ресторанный счёт, Static — статичный QR). Обычно приходят при status=paid, но гейтятся по наличию значения, а не строго по статусу: присутствуют, когда Kaspi вернул значение (счёт, уже получивший его, может пробросить поле и в cancelled/expired), и отсутствуют/null иначе — обрабатывайте как nullable. Перечислены известные на сегодня значения; список может расширяться на стороне Kaspi, поэтому обрабатывайте неизвестные значения как «прочее».
2026-05-28 [changed] Уточнение по POST /invoices/qr: лимит «один активный QR» считается per-кассир (kaspi_connection_id), а не на всю организацию. Если у организации несколько активных касс, можно показывать разные QR одновременно — на каждую кассу свой. В теле запроса добавлен опциональный kaspi_connection_id — для multi-cashier-организаций (если не указан и >1 активной кассы без primary, вернётся 422 connection_ambiguous). Параллельные запросы на одну и ту же кассу больше не блокируются — race ловится conditional UPDATE; проигравший запрос получает 409 superseded с invoice_id уже отменённого счёта, по этому id придёт webhook со status=cancelled. Дедуплицируйте по (invoice_id + status). В sandbox-режиме scope — per-organization (одна касса). Phone-инвойсы (is_qr_token=false) — TTL 24 часа в Kaspi, не затрагиваются supersede-логикой.
2026-05-27 [new] Новый эндпоинт POST /api/v1/clients/check — точечная проверка номера на наличие в Kaspi перед созданием счёта или подписки. Возвращает { phone (нормализованный), has_kaspi, client_name }. Нормализует форматы +7/7/8 и убирает пробелы/дефисы. Не предназначен для массового перебора номеров — на стороне сервера работает детекция аномалий, при срабатывании API-ключ деактивируется без предупреждения, организация блокируется до ручной проверки.
2026-05-09 [new] Новый эндпоинт POST /api/v1/invoices/qr — оплата по QR-коду на экране кассы без номера телефона. В ответе qr_token_url (Kaspi-ссылка) и qr_image_url (PNG 600×600 с логотипом Kaspi). TTL — 5 минут. После оплаты прилетает обычный webhook invoice.status_changed (status: paid). Поддерживаются режимы с каталогом (cart_items, discount_percentage) и без (amount). Для sandbox-организаций добавлен опциональный параметр simulate (paid|cancelled|expired) — создаёт счёт сразу в нужном терминальном статусе с мгновенной отправкой webhook (отдельный вызов /simulate-status больше не нужен, но по-прежнему работает).
2026-05-01 [changed] Параметр price в cart_items — переопределение цены позиции (если не указан, берётся selling_price из каталога)
2026-04-07 [new] Статус processing — счёт создан и ожидает отправки в Kaspi
2026-04-07 [new] Статус error и поле error_message — описание ошибки при сбое отправки
2026-04-07 [new] Параметр discount_percentage — скидка на весь чек (1-99%)
2026-04-07 [new] Параметр bill_immediately — выставить первый счёт подписки сразу
2026-04-07 [changed] POST /invoices/{id}/cancel — теперь работает для статусов pending и processing
2026-04-02 [changed] Обновлена API спецификация: добавлены новые поля в каталоге (kaspi_item_id, nds_percentage, ntin, synced_at и др.), подписках (billing_period_label, status_label, status_color, paused_at, cancelled_at и др.) и возвратах (organization_id, updated_at), обновлён формат пагинации
2026-03-29 [new] Добавлен параметр bill_immediately в POST /subscriptions — немедленное выставление первого счёта при создании подписки
2026-03-29 [new] Добавлено поле created_at в ответы эндпоинтов каталога (GET /catalog, POST /catalog) — дата создания товара в системе в формате ISO 8601
2026-03-27 [new] Запуск публичной API документации с единым источником правды