Обновлено: 3 сентября 2026
Один документ — весь сценарий подключения приёма платежей Kaspi Pay. Для GPT, Claude, Cursor, Copilot и других ИИ-ассистентов.
| Способ | Запрос | Ссылка на оплату | Когда выбирать |
|---|---|---|---|
| Счёт по номеру телефона | POST /invoices |
ссылки для отправки нет — покупатель получает счёт push-уведомлением в приложении Kaspi | вы знаете номер покупателя; счёт ждёт оплаты сутки |
| Оплата по ссылке или QR | POST /invoices/qr |
qr_token_url — ссылка на оплату (payment link). Отправьте её покупателю в WhatsApp или Telegram либо откройте на его телефоне с установленным приложением Kaspi — оплата откроется в нём, сканировать не нужно. На компьютере по ссылке оплатить нельзя — для этого случая рядом показывайте qr_image_url, тот же счёт картинкой PNG |
покупатель в переписке прямо сейчас или стоит у кассы |
| Печатный QR под сделку | POST /static-qr |
print_url — долгоживущая ссылка: её и печатают в QR-коде, и отправляют в мессенджер. Рядом печатается short_code для ручного ввода |
оплатить нужно потом; ссылку кладут в договор, счёт-фактуру, на наклейку |
Подробности и код по каждому способу — шаг 4. Отдельная статья про оплату по ссылке: оплата по ссылке Kaspi.
openapi.json, llms-full.txt) НЕ нужно.
Передай клиенту своими словами 3 факта:
Это не блокер для начала интеграции. Новые организации создаются в песочнице по умолчанию — там счета создаются и без подключённого кассира: деньги никуда не идут, всё имитируется. Можно прямо сейчас писать код, создавать тестовые счета и принимать вебхуки.
Кассир Kaspi обязателен только при переходе в рабочий режим (шаг 8):
без активной сессии кассира реальные счета будут падать с
kaspi_session_not_configured. До этого момента подключение кассира —
параллельная задача клиента, она не должна тормозить разработку.
Когда клиент будет готов идти в прод, ему нужно подключить свой Kaspi Pay через сотрудника с ролью «Кассир» — это делается один раз, двумя способами на выбор. Кратко суть (передай клиенту, чтобы запустил это параллельно):
/settings/connect-cashier. Мастер ведёт по шагам: что такое кассир →
три проверки перед привязкой → номер и роль кассира → предупреждение «не заходите в
приложение Kaspi Pay под этим номером» → код из SMS → готово. Занимает 2–3 минуты.
Сама вкладка «Авторизация Kaspi» — витрина статуса: показывает, подключён ли кассир,
когда была последняя активность, и даёт кнопки «Подключить заново» и «Сменить кассира».Кассирами можно управлять и программно, без кабинета: GET/POST /connections
и авторизация кассира POST /connections/{id}/auth/init →
auth/send-phone → auth/verify-otp, отключение —
auth/logout. Ручки требуют ключа, которому разрешено управление кассирами.
Подробности — в llms-full.txt; для обычной интеграции это
не нужно, хватит мастера в кабинете.
Определи тип проекта клиента:
X-API-Key — это секрет. Он используется ТОЛЬКО на сервере.
Никогда не помещай ключ в код, который выполняется в браузере клиента (frontend) — его
увидят все посетители сайта.
У новых организаций есть мягкий лимит на старте: пока не заполнена и не одобрена короткая анкета «Расскажите о вашем бизнесе», в рабочем режиме можно создать 1 реальный счёт в сутки. В песочнице лимита нет — тестируй сколько нужно, разработку это не тормозит.
Зачем это нужно (объясни клиенту причину): это разовая проверка, по итогам которой лимиты на приём платежей настраиваются под обороты клиента.
Что сделать: клиент заполняет анкету в кабинете на
/business-profile (~5 минут: что продаёт, где продаёт, средний
чек). Одобрение обычно за 1 рабочий день — после него лимит снимается автоматически. При попытке
создать второй счёт за сутки API вернёт 429 kyc_daily_limit_reached (в
meta.reset_at — когда лимит сбросится). Это не блокер разработки: продолжай в
песочнице.
Это делает человек в кабинете на apipay.kz. Нужно войти под ролью Владелец или Разработчик — у роли «Менеджер» доступа к ключам нет. Точный путь по меню:
https://ваш-сайт.kz/webhooks/apipay. Это и есть «webhook URL».
Шаг можно пропустить и вписать адрес позже.Сохрани значения в переменные окружения сервера (никогда — в репозиторий):
APIPAY_API_KEY=...
APIPAY_WEBHOOK_SECRET=...
APIPAY_BASE_URL=https://api.apipay.kz/api/v1
| Параметр | Значение |
|---|---|
| Базовый адрес API | https://api.apipay.kz/api/v1 |
| Авторизация | заголовок X-API-Key: ваш_ключ (только на сервере) |
| Content-Type | application/json |
https://api.apipay.kz/api/v1 —
он не зависит от того, на каком сайте ты читаешь эту инструкцию. Не используй адрес
документации (например localhost или apipay.kz) как адрес API.
| Способ | Запрос | Что возвращает | Срок жизни | Отмена |
|---|---|---|---|---|
| Счёт по номеру телефона | POST /invoices |
201, статус processing. Покупатель получает push в приложении
Kaspi. Ссылки для отправки нет |
24 часа | есть — POST /invoices/{id}/cancel |
| Оплата по ссылке или QR | POST /invoices/qr |
201, статус pending сразу. qr_token_url — ссылка
на оплату: отправьте её покупателю в WhatsApp или Telegram, либо откройте на его
телефоне с приложением Kaspi — оплата откроется в нём, сканировать не нужно. На
компьютере по ссылке не оплатить — показывайте рядом qr_image_url,
PNG того же счёта |
минуты; точный момент — поле qr_expires_at в ответе. Не зашивай
длительность окна константой |
нет — 409 qr_cancel_unsupported |
| Печатный QR под сделку | POST /static-qr |
print_url — долгоживущая ссылка (её и печатают в QR-коде, и отправляют в
мессенджер), short_code для ручного ввода, qr_image_url —
готовый PNG для печати. Счёт создаётся в момент скана |
пока лист не оплачен, не отключён и не истёк заданный expires_at |
отключение листа — DELETE /static-qr/{id} |
| Что говорит клиент | Что делаем |
|---|---|
| «Покупатель пишет мне в WhatsApp/Instagram прямо сейчас» | POST /invoices/qr → отправить qr_token_url в тот же чат |
| «У меня интернет-магазин, покупатель оставляет номер телефона» | POST /invoices — счёт придёт ему push-уведомлением в Kaspi |
| «Клиент оплатит потом» / «нужно напечатать на договоре, счёте, наклейке» | POST /static-qr → напечатать QR с print_url или отправить
эту ссылку в мессенджер |
| «Покупатель стоит у кассы в зале» | POST /invoices/qr → показать qr_image_url на экране |
| Что | Лимит |
|---|---|
| Общий на ключ | 200 запросов/мин |
GET /invoices/{id} | 1000/мин (опрос статуса без вебхуков) |
POST /invoices/bulk | 20/мин |
POST /invoices/qr | 60/мин на организацию |
POST /clients/check | 60/мин и 10 000/сутки на ключ |
POST /catalog/scan | 30/мин и 2000/сутки |
POST /catalog/upload-image | 60/мин и 2000/сутки |
POST /catalog/bulk-delete | 10/мин |
Касса /cashbox/* | 30/мин |
POST /invoices/{id}/simulate-status | 60/мин (отдельный счётчик, общий лимит не расходует) |
| Авторизация кассира | auth/init и auth/send-phone — 5/мин, auth/verify-otp — 10/мин |
Каждый ответ несёт X-RateLimit-Limit и X-RateLimit-Remaining. При
превышении приходит 429 с заголовком Retry-After и полем
retry_after_seconds в теле — жди указанное число секунд, а не повторяй сразу.
Заголовки показывают тот счётчик, где осталось меньше всего, поэтому сравнивай
Remaining с Limit из того же ответа, а не с числом из таблицы.
POST /invoices// выполняется на сервере
const res = await fetch('https://api.apipay.kz/api/v1/invoices', {
method: 'POST',
headers: {
'X-API-Key': process.env.APIPAY_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
phone_number: '87001234567', // обязательно. Формат строго 8XXXXXXXXXX (11 цифр)
amount: 15000, // обязательно. Сумма в тенге, только целая
description: 'Заказ №123', // необязательно, до 60 символов
external_order_id: 'order_123' // необязательно — ваш ID заказа для сверки
})
})
const invoice = await res.json()
// → { id: 42, amount: "15000.00", status: "processing", paid_at: null,
// phone: "87001234567", is_imported: false, created_at: "2026-09-03T12:00:00+00:00" }
// amount — СТРОКА, не число. paid_at заполнится после оплаты.
// is_imported: true — продажа подтянута из истории Kaspi (проведена в приложении мимо ApiPay).
Клиент получит уведомление в приложении Kaspi и оплатит там. Сохрани invoice.id
рядом со своим заказом. Часть полей ответа условная — они появляются, только когда есть
значение (subtotal и discount_sum при скидке или корзине,
error_message при статусе error).
Ссылка у счёта по номеру. У такого счёта нет ссылки, которую можно отправить
покупателю как приглашение к оплате — он приходит push-уведомлением. В ответах
GET /invoices/{id} есть поле kaspi_qr_link: это ссылка на оплату
именно этого счёта, из неё можно нарисовать QR-код. Она null, пока Kaspi не
присвоил счёту свой идентификатор (то есть в статусе processing), и всегда
null в песочнице — на неё нельзя рассчитывать как на основной канал доставки.
Нужна ссылка сразу и наверняка — бери POST /invoices/qr.
POST /invoices/qr// выполняется на сервере. Телефон покупателя НЕ нужен.
const res = await fetch('https://api.apipay.kz/api/v1/invoices/qr', {
method: 'POST',
headers: {
'X-API-Key': process.env.APIPAY_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: 15000, // сумма; тиыны здесь принимаются
description: 'Заказ №123', // необязательно, до 100 символов
external_order_id: 'order_123'
})
})
const qr = await res.json()
// → { id: 77, amount: "15000.00", status: "pending", phone: null, is_qr_token: true,
// qr_token_url: "https://qr.kaspi.kz/...", // ССЫЛКА НА ОПЛАТУ — отправьте покупателю
// qr_image_url: "https://.../qr.png", // тот же счёт картинкой, для экрана кассы
// qr_expires_at: "2026-09-03T12:03:00+00:00" }
// Отправляем ссылку покупателю в мессенджер — сканировать ничего не нужно:
// на телефоне с приложением Kaspi по ссылке откроется готовый счёт.
await sendToCustomerChat(`Оплатите заказ №123 по ссылке: ${qr.qr_token_url}`)
qr_expires_at,
не зашивай числом в код. qr_expires_at ограничивает момент, когда покупатель
ещё может открыть счёт; оплата, начатая под конец окна, завершится и позже. Терминальный
статус ставит Kaspi — ориентируйся на вебхук, а не на свой отсчёт.qr_image_url живёт до qr_expires_at плюс минута, потом отдаёт
404. Это значит «выпусти новый счёт», а не «перезагрузи картинку».POST /invoices/{id}/cancel для такого счёта
вернёт 409 qr_cancel_unsupported. Возврат делается отдельной веткой:
ссылка покупателю POST /qr-refunds/links, затем
POST /qr-refunds/{id}/execute (POST /qr-refunds — deprecated).POST /invoices/{id}/cancel ответит 200 и переведёт счёт в cancelled; в рабочем — 409 qr_cancel_unsupported. Не проверяй ветку отмены на песочнице.invoice.id отдельно.invoice.qr_scanned
(qr_substate: "scanned"), статус при этом остаётся pending.
Это ещё не оплата — товар отдавай только по paid.amount нужен состав:
cart_items. Без него придёт 422 catalog_requires_cart_items
(см. шаг 4.5).POST /static-qrconst res = await fetch('https://api.apipay.kz/api/v1/static-qr', {
method: 'POST',
headers: { 'X-API-Key': process.env.APIPAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 15000, // целая сумма: лист умеет и оплату по номеру телефона
description: 'Договор №14', // до 100 символов
external_order_id: 'deal_14',
single_use: true // одна сделка: после оплаты скан покажет «Оплачено»
})
})
const sheet = await res.json()
// → { id, token, short_code: "K7M9P2Q4", print_url: "https://qr.apipay.kz/...",
// manual_url, qr_image_url, status: "active", paid: false }
// print_url — долгоживущая ссылка: печатайте её QR-кодом ИЛИ отправляйте в мессенджер.
cart_items у организации с каталогом,
чужая позиция каталога, неоднозначный кассир, слишком длинное описание), отбивается
при выпуске, а не при скане. Переиздать напечатанный лист нельзя.DELETE /static-qr/{id}: новые сканы покажут «неактивно»,
уже созданные счета не трогаются.422 description_too_long; организации,
зарегистрированные с 26 августа 2026, живут на этом лимите уже сейчас. Пиши описания коротко
с самого начала. У POST /invoices/qr и POST /static-qr поле
description — это наименование позиции в чеке, там предел Kaspi 100 символов,
но лист, который должен принимать оплату и по номеру телефона, обязан укладываться в те же 60.
GET /invoices/{id}Жизненный цикл статуса: processing → pending →
paid (или cancelled / expired / error).
После частичного возврата оплаченный счёт становится partially_refunded,
после полного — refunded. Счёт из POST /invoices/qr появляется
сразу в pending, минуя processing.
Массовая проверка нескольких счетов сразу — POST /invoices/status/check
с телом {"invoice_ids":[1,2,3]}.
cancelled → paid и expired → paid законны: покупатель успел
оплатить, пока счёт закрывался. Бывает и error → pending. Поэтому
не закрывай заказ навсегда по cancelled или
expired — оставляй возможность принять последующий paid.
Этот шаг нужен, только если клиент хочет видеть в чеке Kaspi позиции
(название, цена, количество), а не одну сумму. Если продаёте «на сумму» —
пропусти шаг и оставайся на обычном POST /invoices.
| Что делаем | Эндпоинт |
|---|---|
| Найти НТИН/GTIN по штрихкоду (маркированные товары) | POST /catalog/scan — 30/мин + 2000/сутки |
| Залить товары пачкой | POST /catalog — от 1 до 100 позиций за запрос |
| Подтвердить, что доехали до Kaspi | GET /catalog?external_refs[]= или вебхук |
external_ref, не по штрихкоду. Kaspi держит
один товар на штрихкод, а в учётной системе под одним штрихкодом бывает несколько позиций —
сверка по штрихкоду перепутает id. external_ref (код номенклатуры из системы
клиента) уникален в пределах организации и однозначен.
POST /catalog/scan (только для маркированных)// выполняется на сервере
const scan = await fetch('https://api.apipay.kz/api/v1/catalog/scan', {
method: 'POST',
headers: { 'X-API-Key': process.env.APIPAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '4870000000001' }) // штрихкод
})
const { data } = await scan.json()
// data[] — кандидаты { id, name, ntin, gtin, barcode, unit_id }. Пустой data[] = товар
// не из Нацкаталога — это НЕ ошибка, заводи без ntin/gtin.
POST /catalog (от 1 до 100 позиций)const res = await fetch('https://api.apipay.kz/api/v1/catalog', {
method: 'POST',
headers: { 'X-API-Key': process.env.APIPAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [
{ name: 'Ручка гелевая синяя 0.5', selling_price: 350, unit_id: 1,
barcode: '4870000000001', external_ref: '1c-000123' }, // external_ref = ключ маппинга
{ name: 'Тетрадь 48 листов клетка', selling_price: 420, unit_id: 1,
barcode: '4870000000002', external_ref: '1c-000124' }
]
})
})
// Ответ ВСЕГДА 202 — и в песочнице, и в рабочем режиме.
// { data: [...], rejected: [...] } — ключ rejected есть всегда, может быть пустым.
outcome, а не по matched_existing.
matched_existing: true говорит только «мы нашли вашу строку» и НЕ говорит, что
по ней открылась работа. Что именно произошло, читай в outcome:
created — заведена новая строка; matched — сматчилась существующая,
правка применена или поставлена в очередь; reissued — переиздана снятая позиция;
unchanged — строка уже совпадает с каталогом Kaspi, делать нечего;
revived — брошенное создание открыто заново; not_started — брошенное
создание найдено, но работа НЕ открыта (дословный повтор того же тела ничего не чинит —
меняй данные позиции либо дожимай через PATCH /catalog/{id}).
data[], невалидные — в rejected[].
422 остаётся только за ошибками самого запроса: items отсутствует,
не массив, пуст или длиннее 100. Повторная заливка идемпотентна — дубли не создаются,
синк можно гонять по расписанию.
Ответ 202 — «принято», не «готово». В песочнице позиция возвращается уже
активной, в рабочем режиме получает pending и уезжает в Kaspi фоном. Финальный
статус (active/failed) узнаётся одним из двух равноправных путей:
// Путь A (просто, для 1С/on-prem): поллинг по external_ref
const check = await fetch('https://api.apipay.kz/api/v1/catalog?' +
'external_refs[]=1c-000123&external_refs[]=1c-000124',
{ headers: { 'X-API-Key': process.env.APIPAY_API_KEY } })
const items = (await check.json()).data
// status: 'active' — товар в Kaspi; 'failed' — смотри error_code.
// Путь B (SaaS с публичным URL): вебхук catalog.item_processed приходит на твой webhook_url
// с полями { id, external_ref, kaspi_item_id, status, operation, error_code, ntin_missing }.
external_refs[]/barcodes[]/ntins[]/ids[] →
422 catalog_match_overflow. Разбивай сверку на батчи по ~100.
Продажа с каталогом: в POST /invoices,
POST /invoices/qr и POST /static-qr вместо amount
передавай cart_items: [{ catalog_item_id, count }] — где
catalog_item_id это id товара из каталога. У организации с
каталогом POST /invoices/qr и POST /static-qr без
cart_items отвечают 422 catalog_requires_cart_items.
POST /catalog/scan) ходит на живую
сессию кассира Kaspi и в рабочем режиме требует подключённого кассира. Заведение каталога
без маркировки (без ntin/gtin) тестируется в песочнице — там лимит
1000 позиций.
Когда счёт оплачен, ApiPay сам отправляет POST на твой адрес вебхука.
Тело события:
{
"event": "invoice.status_changed",
"invoice": {
"id": 42,
"external_order_id": "order_123",
"amount": "15000.00",
"status": "paid",
"paid_at": "2026-09-03T08:35:00Z"
},
"source": "имя вашего ключа",
"timestamp": "2026-09-03T08:35:01Z"
}
| Событие | Когда приходит | Нужно типовой интеграции |
|---|---|---|
invoice.status_changed |
счёт перешёл в pending, paid, cancelled, expired, error или partially_refunded |
Да — это главное событие |
invoice.qr_scanned |
покупатель открыл счёт по ссылке или отсканировал QR; статус остаётся pending |
Нет. Полезно для экрана кассы («клиент открыл оплату»). Это НЕ оплата |
invoice.refunded |
возврат обработан: успех или неудача | Да, если делаете возвраты |
webhook.test |
кнопка «Проверить уведомления» в кабинете | Да — им проверяют, что обработчик жив |
catalog.item_processed |
позиция каталога обработана (по каждой строке заливки) | Да, если заливаете каталог |
subscription.created | подписка создана | Нет |
subscription.payment_succeeded | счёт подписки оплачен | Да, если есть регулярные платежи |
subscription.payment_failed | счёт подписки не оплачен | Да, если есть регулярные платежи |
subscription.grace_period_started | попытки списания исчерпаны, начался льготный период | Нет |
subscription.expired | льготный период истёк либо выбраны все оплаты | Да, если есть регулярные платежи — здесь закрывают доступ |
subscription.paused | подписка поставлена на паузу | Нет |
subscription.resumed | подписка возобновлена | Нет |
subscription.cancelled | подписка отменена либо плательщик явно отказался | Да, если есть регулярные платежи |
receipt.issued | фискальный чек выбит | Нет — только если выбиваете чеки |
receipt.failed | чек выбить не удалось | Нет — только если выбиваете чеки |
qr_refund.identified | клиент отсканировал возвратный QR | Нет |
qr_refund.completed | возврат по QR выполнен | Да, если делаете возвраты по QR |
qr_refund.expired | возвратный QR истёк до идентификации | Нет |
qr_refund.execution_uncertain |
исход возврата не подтверждён — повторять нельзя, нужен ручной разбор | Да, если делаете возвраты по QR |
qr_refund.failed | подтверждение возврата по ссылке не началось — нужна новая ссылка | Нет |
cashbox.shift_closed | кассовая смена закрыта | Да, если закрываете смены программно (шаг 9) |
cashbox.shift_close_failed | смену закрыть не удалось | Да, если закрываете смены программно (шаг 9) |
Технические статусы processing и cancelling вебхуков не порождают
никогда — не жди их.
Каждый вебхук содержит заголовок X-Webhook-Signature: sha256=<hex> —
это HMAC-SHA256 от сырого тела запроса с секретом вебхука.
const crypto = require('crypto')
// rawBody — НЕОБРАБОТАННОЕ тело запроса (Buffer/строка), НЕ результат JSON.parse.
// В Express: app.post('/webhooks/apipay', express.raw({ type: 'application/json' }), ...)
function verifyWebhook(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
const got = Buffer.from(signature || '')
const exp = Buffer.from(expected)
if (got.length !== exp.length) return false
return crypto.timingSafeEqual(exp, got)
}
2xx сразу,
тяжёлую работу делай в фоне.
Если твой сервер не ответил 2xx за 5 секунд (плюс до 3 секунд на соединение),
ApiPay повторит доставку. Всего до 11 попыток (первая + 10 повторов) с
нарастающей задержкой: 10с, 30с, 1м, 1.5м, 2м, 5м, 10м, 15м, 30м, 1ч — около 2 часов.
5xx/429/таймаут → повтор; прочие 4xx → без повтора.
invoice.status_changed → пара (invoice.id, invoice.status);invoice.refunded → пара (refund.id, refund.status);subscription.* → (event, subscription.id, invoice_id).invoice.id, ты потеряешь переход
paid → partially_refunded — возврат просто не дойдёт до твоей системы.
cancelled. Последовательности
cancelled → paid и expired → paid законны и приходят: покупатель
успел заплатить, пока счёт закрывался. Обрабатывай paid после отмены как
нормальную оплату, а не как ошибку.
Если сервера нет (шаг 2b) — вместо вебхука опрашивай статус:
периодически вызывай GET /invoices/{id}, пока не будет paid.
Опрос полезен и при наличии вебхука — как сверка, если все попытки доставки не прошли.
Тестируй в тестовом режиме (песочнице) — счета не уходят в реальный Kaspi, деньги не двигаются. Новая организация в песочнице по умолчанию.
webhook.test. Убедись, что твой обработчик его
принял и подпись сошлась.POST /invoices.
В песочнице оплату счёта имитируют из кабинета: страница «Счета», у
тестового счёта кнопка «Симулировать». Попроси клиента это сделать;
придёт вебхук invoice.status_changed со статусом paid.localhost, ApiPay
до него не достучится — подними туннель (ngrok). Инструкция:
apipay.kz/local-testing. Туннель годится
только для теста в песочнице: рабочий вебхук должен быть на реальном
домене (см. шаг 8).Если у тебя есть sandbox-ключ (X-API-Key тестовой организации,
is_sandbox: true), весь цикл можно пройти программно — не дёргая человека в кабинете.
Два инструмента песочницы:
POST /invoices/{id}/simulate-status — переводит sandbox-счёт из
pending в paid/cancelled/expired/error
или симулирует событие qr_scanned (для счёта по ссылке или QR). Работает
ТОЛЬКО в песочнице; боевой счёт всегда вернёт 403 not_sandbox.
Отдельный лимит — 60 запросов/мин на ключ.GET /webhook-logs и GET /webhook-logs/{id} — read-only логи доставки:
программно проверяешь, что вебхук ушёл и приёмник ответил 2xx (фильтры
invoice_id, event, status).# Полный автономный цикл (sandbox X-API-Key). Требуется jq.
KEY="YOUR_SANDBOX_API_KEY"; BASE="https://api.apipay.kz/api/v1"
# 1. Создать sandbox-счёт (external_order_id_idempotency защищает от дублей при ретрае)
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","external_order_id_idempotency":"autotest-001"}' | jq -r '.id')
# 1a. Дождаться статуса pending — счёт создаётся в processing, переход асинхронный
# (обычно 1-3 секунды); simulate-status требует pending. Поллинг раз в 1-2 сек.
# (Счёт из POST /invoices/qr сразу pending — для него шаг не нужен.)
until [ "$(curl -s "$BASE/invoices/$ID" -H "X-API-Key: $KEY" | jq -r '.status')" = "pending" ]; do sleep 1; done
# 2. Симулировать оплату
curl -s -X POST "$BASE/invoices/$ID/simulate-status" -H "X-API-Key: $KEY" \
-H "Content-Type: application/json" -d '{"status":"paid"}'
# 3. Проверить доставку вебхука invoice.status_changed (status: paid)
# Если ответ пуст — проверьте по ?invoice_id=$ID без event; тип события есть в request_body.
curl -s "$BASE/webhook-logs?invoice_id=$ID&event=invoice.status_changed" \
-H "X-API-Key: $KEY" | jq '.data[] | {event, status, response_status}'
# 4. Частичный возврат (paid → partially_refunded, в песочнице без Kaspi)
curl -s -X POST "$BASE/invoices/$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=$ID" -H "X-API-Key: $KEY" | jq '.data[] | {event, status}'
# Другие ветки (каждая — новый sandbox-счёт, дождаться pending как в шаге 1a):
# {"status":"cancelled"} → invoice.status_changed (cancelled)
# {"status":"expired"} → invoice.status_changed (expired)
# {"status":"error","error_message":"Тест"} → error_code: sandbox_simulated_error
# Оплата по ссылке или QR: POST /invoices/qr с телом {"amount":5000,"description":"QR autotest"}
# (phone_number не нужен; счёт сразу pending, в ответе qr_token_url) + {"status":"qr_scanned"} →
# invoice.qr_scanned (qr_substate: scanned, статус остаётся pending;
# повторный qr_scanned → 400 already_scanned)
Sandbox-вебхуки по счетам доставляются с 3 попытками (задержки 5с/15с; в рабочем режиме — до
11 попыток ~2ч), успех — любой HTTP 2xx. Критерий успеха теста: по каждому шагу
в /webhook-logs есть ожидаемое событие со status: success. Готовый
копипаст-промпт для тест-агента — на apipay.kz/prompts.
POST /invoicesHTTP / поле error | Причина | Что сделать |
|---|---|---|
422, ошибка в phone_number | Телефон не в формате 8XXXXXXXXXX | Ровно 8 и 10 цифр, без +7, пробелов и скобок |
422 description_too_long |
Описание длиннее 60 символов (с 05.09.2026 для всех; для организаций, зарегистрированных с 26.08.2026, — уже сейчас) | Укоротить описание до 60 символов. Kaspi всё равно показывает покупателю только первые 60 |
422 amount_must_be_whole_tenge | Сумма с тиынами в счёте по номеру телефона | Округлить до целых тенге либо использовать POST /invoices/qr — там тиыны принимаются |
| 401 | Неверный, отсутствующий или истёкший X-API-Key |
Проверь ключ и заголовок (шаг 3). Здесь помогает перевыпуск ключа |
403 organization_archived |
Организация этого ключа отправлена в архив. Ключ при этом активен | Перевыпуск ключа НЕ поможет — доступ возвращает владелец аккаунта в кабинете. Не путай с 401 |
403 tariff_inactive |
Подписка на ApiPay не активна. Приходит на любой платной операции; чтение (GET) продолжает работать |
Клиенту оплатить тариф в кабинете. Льготного периода нет — блокировка наступает сразу после expires_at (он приходит в теле вместе с reason) |
400, error: Organization not found or not verified |
Организация не подключена или не верифицирована (рабочий режим) | Вернись к шагу 2 — подключить кассира и дождаться верификации; пока тестируй в песочнице |
400 kaspi_session_not_configured |
Сессия Kaspi не настроена (только рабочий режим — в песочнице эта ошибка не возникает) | Клиенту подключить кассира мастером: Настройки → «Авторизация Kaspi» → кнопка подключения, либо поддержка WhatsApp. Подробно — шаг 2a и /connect-cashier |
409 kaspi_session_expired |
Сессия кассира Kaspi мертва, счёт не создан (только рабочий режим) | Повтор запроса не поможет. Клиенту переподключить кассира по SMS — тот же мастер |
503 kaspi_session_invalid |
Сессия Kaspi истекла или неисправна (только рабочий режим) | Переподключить кассу по SMS (см. ниже) |
422 connection_ambiguous | У организации несколько касс, основная не выбрана | Передать в запросе kaspi_connection_id нужной кассы |
422 catalog_requires_cart_items |
У организации включён каталог, а счёт пришёл одной суммой (POST /invoices/qr, POST /static-qr) |
Взять catalog_item_id в GET /catalog и передать cart_items (шаг 4.5) |
400 sandbox_invoice_limit | Лимит тестовых счетов исчерпан (1000) | Клиенту нажать «Очистить тестовые данные» на странице «Счета» |
| 429 | Превышен лимит запросов (таблица лимитов — в шаге 4) | Подожди столько секунд, сколько указано в заголовке Retry-After и поле retry_after_seconds, затем повтори |
429 trial_daily_limit |
Пробный тариф: до 50 реальных счетов в сутки через API | Клиенту оплатить тариф — ограничение снимается. До этого лимит сбрасывается на следующие сутки; ждать столько, сколько в Retry-After |
429 kyc_daily_limit_reached |
Молодая орг: до одобрения анкеты о бизнесе — 1 реальный счёт/сутки (песочница без лимита) | Клиенту заполнить анкету на /business-profile (~5 мин), лимит снимется после одобрения. В meta.reset_at — когда сбросится. См. шаг 2c |
429 tariff_limit_reached |
Исчерпан дневной лимит счетов оплаченного тарифа (Старт до 30, Бизнес до 100, Про до 300, Про Макс до 600). Считаются только счета через API; кабинетные и песочные не входят. Разовое превышение не блокирует — отказ приходит при систематическом превышении либо при исчерпанном бюджете помесячного подсчёта | Не повторяй раньше meta.reset_at (Retry-After — секунды до сброса). meta.mode: daily — сутки, monthly — блок 30 дней. Снимается переходом на тариф выше сразу после оплаты. Расход — в GET /users/me → daily_usage |
403 kyc_rejected |
Приём платежей закрыт по итогам проверки бизнеса (статус орг blocked) |
Не повторяемая. Клиенту написать в поддержку WhatsApp, если считает это ошибкой |
422 webhook_url_requires_domain / webhook_url_tunnel_forbidden |
Рабочий вебхук для ещё не одобренной орг: указан IP или туннель (ngrok и подобные) | Указать адрес на реальном домене (публичный HTTPS). Туннель — только для теста в песочнице (шаг 6) |
Полный каталог кодов ошибок с пометкой, какие имеет смысл повторять, — apipay.kz/errors.md (человеку удобнее apipay.kz/errors).
errorСоздание счёта по номеру телефона асинхронное: POST /invoices возвращает
201 со статусом processing, дальше счёт уходит в Kaspi в фоне.
Если что-то пошло не так на стороне Kaspi — это не ошибка HTTP, а статус
error у счёта. Проверяй через GET /invoices/{id}:
status — стал error;error_code — стабильный машиночитаемый слаг причины. По нему и строй
switch-логику;error_message — та же причина текстом, только для показа человеку.
Не завязывай логику на подстроки этого текста — он может меняться.Например client_not_found — номер не зарегистрирован в Kaspi (попроси клиента
дать номер с установленным приложением Kaspi, повтор не поможет);
network_unavailable — Kaspi временно недоступен (повтори создание счёта позже).
Полный список слагов и их retryable-разметку смотри в
errors.md.
kaspi_session_invalid, kaspi_session_expired)Сессия кассира рассчитана на долгую работу — ежедневно или по расписанию
переподключать кассу не нужно. Прерваться она может, например, если
кто-то вошёл в Kaspi или Kaspi Pay под номером кассира либо Kaspi сбросил сессию на
своей стороне. Ретраи запроса не помогут: владелец организации один раз переподключает
кассу по SMS — кабинет, Настройки → «Авторизация Kaspi» → кнопка подключения,
либо через поддержку. Инструкция: /connect-cashier.
Программно состояние сессии видно в GET /account/health →
connection.needs_reauth — так «сессия слетела» ловится опросом, без вебхука.
Открой в кабинете Настройки → «Лог уведомлений» — там видно каждую отправку вебхука: адрес, HTTP-код ответа твоего сервера, отправленное тело и полученный ответ. Это главный инструмент диагностики:
localhost → ApiPay до него не достучится, нужен туннель (шаг 6).redirect not followed → адрес отвечает переадресацией
301/302. Такие переходы теряют тело запроса, поэтому доставка
считается неуспешной. Указывай сразу конечный адрес на https://.GET /invoices/{id}.
Пауза снимается сама первой успешной доставкой либо кнопкой
«Проверить уведомления» в кабинете. Текущее состояние видно в списке
API-ключей: поле webhook_status — active, paused
или disabled.
После ответа 4xx (кроме 429) автоматических повторов
нет вовсе: попытка считается неуспешной, и этот переход больше сам не
отправится. Переслать его можно только вручную — в кабинете, «Лог уведомлений», кнопка
повтора у неудачной записи.
Когда тесты в песочнице прошли:
kaspi_session_not_configured (см. шаг 7).
Инструкция: /connect-cashier. Дождись подтверждения, что
кассир подключён, и только после этого переключай режим.
// Два GET-запроса показывают, готов ли аккаунт к рабочему режиму.
const health = await (await fetch('https://api.apipay.kz/api/v1/account/health',
{ headers: { 'X-API-Key': process.env.APIPAY_API_KEY } })).json()
// connection — подключён ли кассир и жива ли сессия (connection.needs_reauth),
// tariff.status — активна ли подписка, invoicing.accumulating — копятся ли счета.
const tariff = await (await fetch('https://api.apipay.kz/api/v1/tariff',
{ headers: { 'X-API-Key': process.env.APIPAY_API_KEY } })).json()
// снимок подписки клиента на ApiPay: срок, суммы. Это плата за сервис, не оборот клиента.
Оба запроса — только чтение, лимитов тарифа не расходуют и работают даже при неактивной
подписке. Если tariff.status не активен или кассир не подключён — не отправляй
клиента переключать режим, сначала закрой эти пункты.
429 kyc_daily_limit_reached) — заполни заранее, чтобы к запуску лимит уже сняли. Зачем это — см. шаг 2c.422 webhook_url_requires_domain/webhook_url_tunnel_forbidden. Туннель из шага 6 — только для теста в песочнице.blocked,
создание счёта отдаёт 403 kyc_rejected — это терминальный отказ. Анкету повторно
подавать нельзя; клиенту нужно написать в поддержку
WhatsApp, если он считает это ошибкой.
Готово — интеграция приёма платежей завершена.
Нужно, если у клиента есть касса Kaspi и он хочет закрывать смены, забирать отчёты и сверять кассу со счетами не руками. Требует подключённого кассира (шаг 8) и активной подписки; у кассовых ручек отдельный лимит — 30 запросов в минуту на ключ.
Проверьте предусловие до того, как писать код. Кассовые ручки работают
только у организаций, к аккаунту Kaspi Pay которых подключена Kaspi Касса (ОФД) — та же
связка, что включает каталог товаров. Если клиент принимает оплаты через Kaspi Pos без
ОФД, кассовых смен у него не существует: раздел «Касса» в кабинете не показывается, а
ручки отвечают 409 — cashbox_kkm_unknown у
GET /cashbox/shifts и POST /cashbox/shifts/close,
rfo_missing у GET /cashbox/summary и обоих тумблеров. Если ОФД у
клиента нет — ждать нечего, номер кассы сам не появится.
Если клиент утверждает, что Kaspi Касса у него есть, а ручки всё равно отдают
409: при нескольких кассах передайте kaspi_connection_id нужной
точки, а состояние организации пересверяется в кабинете — Настройки → вкладка
«Организация» → карточка «Информация об организации» →
кнопка «Обновить информацию» (доступна только владельцу организации, не
чаще раза в 5 минут) — либо переподключением кассира. Предупредите клиента: то же действие
включает каталог товаров, и после него POST /invoices/qr и
POST /static-qr без cart_items начнут отвечать
422 catalog_requires_cart_items, а уже напечатанные QR-листы без состава
перестанут работать — переиздать их нельзя.
В песочнице касса отвечает всегда — детерминированными данными,
независимо от того, подключена ли она в бою и есть ли кассир. Интеграцию, прошедшую в
песочнице, обязательно прогоните на боевой организации клиента, иначе первые
409 вы увидите уже в проде. Раздел «Касса» в кабинете тестовой организации
при этом появляется, только если организация создана с ответом «касса есть» (ответ
меняется в настройках).
GET /cashbox/summary?date=YYYY-MM-DD — наличные за день: остаток в кассе,
внесено, изъято, продажи и возвраты наличными. Суммы приходят строками «N.NN» и могут
быть null — это «Kaspi не отдал поле», а не ноль. Оплаты по счетам ApiPay
сюда не попадают.GET /cashbox/shifts?date_from=&date_to= — список смен. Обе границы
обязательны, окно не длиннее 31 дня, иначе 422. Запрос идёт в кассу Kaspi
и отвечает не мгновенно. Этот вызов обязателен перед сверкой: он
сохраняет смены на нашей стороне.GET /cashbox/shifts/{id}/report — отчёт по смене. Возвращает не файл, а
подписанную ссылку со сроком жизни около 15 минут: скачивай сразу, ссылку не храни и не логируй — она открывается без ключа.GET /cashbox/reconciliation?shift_id={id} — сверка: рядом ваши оплаченные
счета (ours, окно по дате оплаты) и итог кассы (kaspi) плюс
discrepancies с причинами расхождения. Без шага 2 придёт
404 cashbox_shift_not_found.verdict,
comparable и delta в ответе нет — не придумывай их и не вычитай
одну цифру из другой. При нескольких кассах помни: sales считаются по кассиру
смены, а refunds — по всей организации.
POST /cashbox/shifts/close с телом
{"client_operation_id": "…", "shift_number": 106} отвечает 202.
Результат читай поллингом GET /cashbox/operations/{id} либо вебхуками
cashbox.shift_closed / cashbox.shift_close_failed
(дедуплицируй по паре event + operation.id).
client_operation_id уникален на организацию: повтор тем же ключом →
409 cashbox_duplicate_operation с id уже идущей операции.resolution.safe_to_retry равен true только при статусе
failed. Отказ не гарантирует, что смена осталась открытой: если ответ Kaspi
не дошёл, она могла закрыться. Повтор безопасен — уже закрытая смена считается успехом.client_operation_id: прежний после
отказа не освобождается.PUT /cashbox/settings/auto-close и
PUT /cashbox/settings/auto-withdrawal с телом {"enabled": true}
отвечают {"changed":…, "new_value":…}. Запрос идемпотентен по живому значению
на кассе: changed:false — не ошибка, там уже стояло запрошенное значение.
503 cashbox_toggle_in_progress — настройку меняет другой запрос;
503 cashbox_toggle_unavailable — изменение не применено.
GET /cashbox/settings отдаёт сохранённые значения и может вернуть
null — это «сохранённого значения ещё нет», а не «выключено». Живое состояние
приходит в других ответах: автозакрытие — в GET /cashbox/shifts,
автоизъятие — в GET /cashbox/summary.
Подробнее — автозакрытие смены, отчёт по смене, сверка кассы со счетами.
| Что | Значение |
|---|---|
| Базовый адрес API | https://api.apipay.kz/api/v1 |
| Авторизация | X-API-Key (заголовок, только на сервере) |
| Способ 1. Счёт по номеру телефона | POST /invoices — push в приложении Kaspi, живёт 24 часа, ссылки для отправки нет |
| Способ 2. Оплата по ссылке или QR | POST /invoices/qr — в ответе qr_token_url (ссылка на оплату, отправляется покупателю в мессенджер) и qr_image_url (PNG для экрана). Окно на оплату — в qr_expires_at |
| Способ 3. Печатный QR под сделку | POST /static-qr — print_url (долгоживущая ссылка: печать или мессенджер), short_code для ручного ввода, отключение DELETE /static-qr/{id} |
| Статус счёта | GET /invoices/{id}, POST /invoices/status/check |
| Готовность аккаунта к проду | GET /account/health, GET /tariff |
| Сумма счёта на номер | целые тенге, 1 … 99 999 999; дробная → 422 amount_must_be_whole_tenge (копейки принимает только POST /invoices/qr) |
| Описание счёта | до 60 символов (с 05.09.2026 — жёстко, 422 description_too_long); Kaspi показывает покупателю первые 60 |
| Отмена / возврат | POST /invoices/{id}/cancel (только счёт на номер: счёт по ссылке или QR отменить нельзя, 409 qr_cancel_unsupported), POST /invoices/{id}/refund |
| Подписки (рекуррент) | POST /subscriptions + /pause, /resume, /cancel |
| Каталог | POST /catalog (1–100 позиций, ответ всегда 202, ветвись по outcome) |
| Касса: смены и отчёт | GET /cashbox/shifts, GET /cashbox/shifts/{id}/report, POST /cashbox/shifts/close (202) |
| Касса: наличные и сверка | GET /cashbox/summary, GET /cashbox/reconciliation?shift_id= |
| Подпись вебхука | X-Webhook-Signature: sha256=…, HMAC-SHA256 от сырого тела |
| Дедупликация вебхуков | по паре (invoice.id, invoice.status) |
| Поддержка | WhatsApp +7 700 307 65 12 |
Другие материалы для машин (нужны редко — этого плейбука достаточно для типовой интеграции): llms.txt — индекс, с чего начать · llms-full.txt — полный свод по всем ручкам · errors.md — каталог кодов ошибок · openapi.json — спецификация OpenAPI · оплата по ссылке Kaspi — разбор способа 2 для человека.
CRM и платформам, которые подключают чужих мерчантов: см.
Partner API (другой механизм — X-Partner-Key).