Один документ — весь сценарий подключения приёма платежей Kaspi Pay. Для GPT, Claude, Cursor, Copilot и других ИИ-ассистентов.
openapi.json, docs.html) НЕ нужно.
Передай клиенту своими словами 3 факта:
Это не блокер для начала интеграции. Новые организации создаются в песочнице по умолчанию — там счета создаются и без подключённого кассира: деньги никуда не идут, всё имитируется. Можно прямо сейчас писать код, создавать тестовые счета и принимать вебхуки.
Кассир Kaspi обязателен только при переходе в рабочий режим (шаг 8):
без активной сессии кассира реальные счета будут падать с
kaspi_session_not_configured. До этого момента подключение кассира —
параллельная задача клиента, она не должна тормозить разработку.
Когда клиент будет готов идти в прод, ему нужно подключить Kaspi Business как «Кассира» — это делается один раз, двумя способами на выбор. Кратко суть (передай клиенту, чтобы запустил это параллельно):
Определи тип проекта клиента:
X-API-Key — это секрет. Он используется ТОЛЬКО на сервере.
Никогда не помещай ключ в код, который выполняется в браузере клиента (frontend) — его
увидят все посетители сайта.
У новых организаций есть мягкий лимит на старте: пока не заполнена и не одобрена короткая анкета «Расскажите о вашем бизнесе», в рабочем режиме можно создать 1 реальный счёт в сутки. В песочнице лимита нет — тестируй сколько нужно, разработку это не тормозит.
Зачем это нужно (объясни клиенту причину — тогда он не воспримет проверку как недоверие): это разовая проверка ради безопасности самого продавца. Она снижает риск, что его Kaspi-кассу зацепит антифродом вместе с мошенниками, и позволяет настроить лимиты под его обороты.
Что сделать: клиент заполняет анкету в кабинете на
/business-profile (~5 минут: что продаёт, где продаёт, средний
чек). Одобрение обычно за 1 рабочий день — после него лимит снимается автоматически. При попытке
создать второй счёт за сутки API вернёт 429 kyc_daily_limit_reached (в
meta.reset_at — когда лимит сбросится). Это не блокер разработки: продолжай в
песочнице.
Это делает человек в кабинете на apipay.kz. Нужно войти под ролью Владелец или Разработчик — у роли «Менеджер» доступа к ключам нет. Точный путь по меню:
https://ваш-сайт.kz/webhooks/apipay).Сохрани значения в переменные окружения сервера (никогда — в репозиторий):
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 |
| Лимит запросов | 200 запросов в минуту на ключ (отдельный лимит 60/мин — только у POST /clients/check) |
https://api.apipay.kz/api/v1 —
он не зависит от того, на каком сайте ты читаешь эту инструкцию. Не используй адрес
документации (например localhost или apipay.kz) как адрес API.
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', // необязательно, до 500 символов
external_order_id: 'order_123' // необязательно — ваш ID заказа для сверки
})
})
const invoice = await res.json()
// → { id, amount, status: 'processing', phone, created_at }
Клиент получит уведомление в приложении Kaspi и оплатит там. Сохрани invoice.id
рядом со своим заказом.
GET /invoices/{id}Жизненный цикл статуса: processing → pending →
paid (или cancelled / expired / error).
Массовая проверка нескольких счетов сразу — POST /invoices/status/check
с телом {"invoice_ids":[1,2,3]}.
Этот шаг нужен, только если клиент хочет видеть в чеке Kaspi позиции
(название, цена, количество), а не одну сумму. Если продаёте «на сумму» —
пропусти шаг и оставайся на обычном POST /invoices.
| Что делаем | Эндпоинт |
|---|---|
| Найти НТИН/GTIN по штрихкоду (маркированные товары) | POST /catalog/scan — 30/мин + 2000/сутки |
| Залить товары пачкой | POST /catalog — до 50 позиций за запрос |
| Подтвердить, что доехали до 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 (батч до 50)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. Каждый item в ответе несёт: id, status (pending/active),
// matched_existing (true = сматчен с существующим, дубля нет), ntin_missing.
id с matched_existing: true — дубли и «мёртвые»
строки не создаются. Можно гонять синк по расписанию.
Ответ 202 — «принято», не «готово». Финальный статус (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, error_code, ntin_missing }.
external_refs[]/barcodes[]/ntins[]/ids[] →
422 catalog_match_overflow. Разбивай сверку на батчи по ~100.
Продажа с каталогом: в POST /invoices вместо amount
передавай cart_items: [{ catalog_item_id, count }] — где
catalog_item_id это id товара из каталога.
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-05-19T08:35:00Z"
},
"source": "имя вашего ключа",
"timestamp": "2026-05-19T08:35:01Z"
}
Другие события: invoice.refunded, subscription.payment_succeeded,
subscription.payment_failed, subscription.grace_period_started,
subscription.expired, webhook.test.
Каждый вебхук содержит заголовок 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.id.
Если сервера нет (шаг 2b) — вместо вебхука опрашивай статус:
периодически вызывай GET /invoices/{id}, пока не будет paid.
Опрос полезен и при наличии вебхука — как сверка, если все 11 попыток не прошли.
Тестируй в тестовом режиме (песочнице) — счета не уходят в реальный 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 сек.
# (QR-счёт из 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) + {"status":"qr_scanned"} →
# invoice.qr_scanned (qr_substate: scanned, статус остаётся pending;
# повторный qr_scanned → 400 already_scanned)
Sandbox-вебхуки доставляются с 3 попытками (backoff 5с/15с; в рабочем режиме — до 11 попыток ~2ч),
успех — любой HTTP 2xx. Критерий успеха теста: по каждому шагу в /webhook-logs
есть ожидаемое событие со status: success. Готовый копипаст-промпт для тест-агента —
на apipay.kz/prompts.
POST /invoicesHTTP / поле error | Причина | Что сделать |
|---|---|---|
422, ошибка в phone_number | Телефон не в формате 8XXXXXXXXXX | Ровно 8 и 10 цифр, без +7, пробелов и скобок |
| 401 | Неверный, отсутствующий или истёкший X-API-Key |
Проверь ключ и заголовок (шаг 3) |
400 organization_required | Организация не подключена | Вернись к шагу 2 — подключить кассира |
400 Organization not found or not verified |
Организация есть, но не верифицирована (рабочий режим) | Дождаться верификации организации; пока тестируй в песочнице |
400 kaspi_session_not_configured |
Сессия Kaspi не настроена (только рабочий режим — в песочнице эта ошибка не возникает) | Клиенту подключить кассира: кабинет → Настройки → «Авторизация Kaspi», либо поддержка WhatsApp. Подробно — шаг 2a и /connect-cashier |
503 kaspi_session_invalid |
Сессия Kaspi истекла или неисправна (только рабочий режим) | Переподключить кассу по SMS (см. ниже) |
422 connection_ambiguous | У организации несколько касс, основная не выбрана | Передать в запросе kaspi_connection_id нужной кассы |
400 sandbox_invoice_limit | Лимит тестовых счетов исчерпан | Очистить песочницу в кабинете |
| 429 | Превышен лимит запросов (200/мин на ключ; 60/мин — только у POST /clients/check) |
Подожди и повтори; смотри retry_after |
429 kyc_daily_limit_reached |
Молодая орг: до одобрения анкеты о бизнесе — 1 реальный счёт/сутки (песочница без лимита) | Клиенту заполнить анкету на /business-profile (~5 мин), лимит снимется после одобрения. В meta.reset_at — когда сбросится. См. шаг 2c |
403 kyc_rejected |
Приём платежей закрыт по итогам проверки бизнеса (статус орг blocked) |
Не повторяемая. Клиенту написать в поддержку WhatsApp, если считает это ошибкой |
422 webhook_url_requires_domain / webhook_url_tunnel_forbidden |
Рабочий вебхук для ещё не одобренной орг: указан IP или туннель (ngrok и подобные) | Указать адрес на реальном домене (публичный HTTPS). Туннель — только для теста в песочнице (шаг 6) |
Полная таблица ошибок (QR-счета, возвраты) — в docs.html.
errorСоздание счёта асинхронное: POST /invoices возвращает 201 со
статусом processing, дальше счёт уходит в Kaspi в фоне. Если что-то пошло
не так на стороне Kaspi — это не ошибка HTTP, а статус
error у счёта. Проверяй через GET /invoices/{id}:
status — стал error;error_code — стабильный машиночитаемый слаг причины. По нему и строй
switch-логику;error_message — та же причина текстом, только для показа человеку.
Не завязывай логику на подстроки этого текста — он может меняться.У ApiPay есть канонический каталог error_code (стабильные слаги, с пометкой,
какие имеет смысл повторять): например client_not_found — номер не
зарегистрирован в Kaspi (попроси клиента дать номер с установленным приложением Kaspi,
повтор не поможет), network_unavailable / kaspi_throttled —
Kaspi временно недоступен или троттлит (повтори создание счёта позже). Полный список
слагов и их retryable-разметку смотри в разделе Error Codes в
docs.html.
kaspi_session_invalid)Сессия кассира стабильна и обычно живёт месяцами — ежедневно или
по расписанию переподключать кассу не нужно. Прерваться она может
только при конкретных событиях: владелец вошёл в Kaspi с другого устройства или
Kaspi сам сбросил сессию. В этом случае API отдаёт 503 kaspi_session_invalid.
Ретраи запроса не помогут: владелец организации один раз переподключает кассу по SMS —
кабинет, Настройки → «Авторизация Kaspi», либо через поддержку. Инструкция:
/connect-cashier.
Открой в кабинете Настройки → «Лог уведомлений» — там видно каждую отправку вебхука: адрес, HTTP-код ответа твоего сервера, отправленное тело и полученный ответ. Это главный инструмент диагностики:
localhost → ApiPay до него не достучится, нужен туннель (шаг 6).Когда тесты в песочнице прошли:
kaspi_session_not_configured (см. шаг 7).
Инструкция: /connect-cashier. Дождись подтверждения, что
кассир подключён, и только после этого переключай режим.
429 kyc_daily_limit_reached) — заполни заранее, чтобы к запуску лимит уже сняли. Зачем это — см. шаг 2c.422 webhook_url_requires_domain/webhook_url_tunnel_forbidden. Туннель из шага 6 — только для теста в песочнице.blocked,
создание счёта отдаёт 403 kyc_rejected — это терминальный отказ. Анкету повторно
подавать нельзя; клиенту нужно написать в поддержку
WhatsApp, если он считает это ошибкой.
Готово — интеграция приёма платежей завершена.
| Что | Значение |
|---|---|
| Базовый адрес API | https://api.apipay.kz/api/v1 |
| Авторизация | X-API-Key (заголовок, только на сервере) |
| Создать счёт | POST /invoices |
| Статус счёта | GET /invoices/{id}, POST /invoices/status/check |
| Отмена / возврат | POST /invoices/{id}/cancel, POST /invoices/{id}/refund |
| Подписки (рекуррент) | POST /subscriptions + /pause, /resume, /cancel |
| Подпись вебхука | X-Webhook-Signature: sha256=…, HMAC-SHA256 от сырого тела |
| Поддержка | WhatsApp +7 708 516 74 89 |
Полная документация (нужна редко — этого плейбука достаточно для типовой интеграции): docs.html · apipay-api-docs.md · openapi.json · llms.txt
CRM и платформам, которые подключают чужих мерчантов: см.
Partner API (другой механизм — X-Partner-Key).