Приём Kaspi Pay по REST — над аккаунтом мерчанта.
Один REST-API, чтобы выставлять QR и счета на номер, ловить статус и читать журнал событий. Платформа держит подключение к Kaspi за вас; вы делаете HTTP-запросы.
Базовый адрес
https://api.aitole.kz
Соглашения
- Деньги — целые тенге (
integer). Дробные суммы →422 amount_not_whole. - Провайдерский сбой при создании — не 5xx: операция возвращается в статусе
failedс кодом причины и HTTP 200. Проверяйте полеstatus, а не только HTTP-код. - Каждый ответ несёт заголовок
X-Request-Id— указывайте его в обращениях в поддержку. - Все тела — JSON, поля в ответах —
snake_case.
Два контура: ключ для платежей, токен для кабинета.
Приём платежей идёт по API-ключу. Управление подключениями и ключами — по JWT из кабинета. Ключ не управляет подключениями — это защита на случай его утечки.
API-ключ — для /v2/*
Передавайте ключ в заголовке X-API-Key. Формат: sk_live_… (боевой) или sk_test_… (тестовый, безлимит по частоте).
X-API-Key: sk_live_hLR0vCVY_…У ключа есть scopes: список разрешённых инструментов (tools: "*" или массив вроде ["create_qr"]) и потолок суммы (maxAmount: число или null). Инструмент вне списка → 403 tool_not_allowed; сумма выше потолка → 422 amount_over_key_cap.
JWT — для /me/*
Логин отдаёт токен; передавайте его как Authorization: Bearer. Ключ (X-API-Key) в кабинет не пускают вовсе.
curl -X POST https://api.aitole.kz/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"[email protected]","password":"…"}' ← 200 { "token": "eyJhbGciOiJIUzI1NiJ9…" }
Единый конверт ошибки.
Любая ошибка — один и тот же JSON. Ветвитесь по error.code, не по тексту. Полный список кодов — внизу.
{
"error": {
"code": "amount_over_key_cap",
"message": "Сумма операции превышает потолок ключа",
"category": "validation",
"request_id": "req_79b6…"
}
}Онбординг Kaspi — из кабинета, по SMS.
Подключение аккаунта мерчанта — три auth-шага под JWT (owner/admin). Секрет сессии платформа хранит зашифрованным и наружу не отдаёт. Все ручки — за Authorization: Bearer.
Создаёт черновик подключения. Тело: { "driver": "http" } для боевого Kaspi или { "driver": "mock" } для песочницы. Ответ — безопасный вид без секрета сессии.
{ "id": "7a3069ab-…", "driver": "http",
"status": "onboarding", "is_default": false, "created_at": "2026-09-08T…" }Затем — три шага авторизации по :id
| Шаг | Тело | Ответ (stage) |
|---|---|---|
POST /me/connections/:id/auth/init | — | phone_required |
POST /me/connections/:id/auth/phone | {"phone":"+7701…"} | otp_required |
POST /me/connections/:id/auth/otp | {"code":"1234"} | active |
+77001234567) — к формату Kaspi платформа приведёт сама.otp → active подключение готово. Первое активное подключение становится дефолтным — по нему пойдут платежи, если не указать connectionId. Последующие дефолт не перехватывают: назначайте явно через POST /:id/default. Управление: GET /me/connections, POST /:id/logout, DELETE /:id.Сессия Kaspi и почему она умирает.
Подключение держится на живой сессии Kaspi, привязанной к номеру кассира. У Kaspi одна активная сессия на номер — и это главный источник простоев, а не редкий край.
Что убивает сессию
- Вход в приложение Kaspi Pay под тем же номером. Ваша сессия будет вытеснена немедленно — Kaspi сигналит это отдельным кодом. Поэтому номер кассира, отданный платформе, должен быть выделенным: под ним не заходят руками.
- Естественное истечение сессии на стороне Kaspi.
Что делает платформа
Как только провайдер сообщает, что сессия не признаётся, подключение переводится из active в session_expired, и в журнал пишется событие connection.session_expired. Опрос статусов по такому подключению прекращается — платформа не долбит Kaspi мёртвой сессией.
| Статус подключения | Что значит |
|---|---|
onboarding | Черновик: auth-шаги ещё не завершены. |
active | Рабочее состояние, платежи проходят. |
session_expired | Сессия Kaspi мертва. Нужен повторный онбординг (auth/init → phone → otp). |
logged_out | Отключено вами через POST /:id/logout. |
Как это выглядит с вашей стороны
- Новые платежи отвергаются с
409 no_active_connection. - В
/v2/eventsпоявляетсяconnection.session_expired. GET /me/connectionsпоказывает статус подключения.
GET /me/connections/:id/health. Восстановление — те же три auth-шага на существующем подключении.Несколько Kaspi-аккаунтов
У одного аккаунта платформы может быть несколько боевых подключений — например, по одному на юрлицо или на точку. Касса для платежа выбирается в таком порядке:
connection_idв теле запроса — явный выбор.- Подключение, к которому привязан ключ. Привязка задаётся при выпуске ключа в кабинете.
- Подключение с пометкой «основное».
- Единственное активное подключение своего контура.
connection_id: ответ будет 403 forbidden. Так ключ, отданный подрядчику или вшитый в отдельный проект, не открывает остальные кассы аккаунта.Если активных подключений контура несколько, ни одно не помечено основным и выбор не сделан ни ключом, ни запросом, платёж отклоняется с 409 connection_ambiguous. Догадываться, на какую кассу отправить деньги, платформа не станет.
Как узнать кассу платежа
Каждое платёжное событие несёт connection_id — и в журнале (GET /v2/events), и в теле вебхука. События подключения несут его же. События про подписку и тариф кассы не имеют: они про аккаунт целиком.
Вебхук можно подписать на конкретную кассу: при создании эндпоинта передайте connection_id. Такой эндпоинт получает только её платежи и не получает событий уровня аккаунта. Эндпоинт без connection_id получает всё, как раньше.
Кассу живого эндпоинта меняют через PATCH /me/webhooks/:id с полем connection_id (или null, чтобы снова получать всё). Пересоздавать эндпоинт ради этого не нужно и вредно: у нового будет другой секрет подписи.
shop-a-1024 вместо 1024. Иначе второй проект получит ответ первого.Создать QR.
Выставляет QR-платёж. Требует инструмент create_qr в scopes ключа и обязательный заголовок Idempotency-Key.
Параметры тела
| Поле | Описание | |
|---|---|---|
amount | req | Сумма, целые тенге. |
comment | opt | Комментарий к платежу. |
items | opt | Позиции чека: [{ "name", "amount" }]. |
metadata | opt | Ваш произвольный JSON — вернётся в операции и событиях. |
curl -X POST https://api.aitole.kz/v2/qr \ -H "X-API-Key: sk_live_…" \ -H "Idempotency-Key: order-42" \ -H "Content-Type: application/json" \ -d '{"amount": 5000, "comment": "Заказ №42"}'
{
"id": "op_d7834b…",
"type": "qr",
"status": "pending",
"amount": 5000,
"qr_token": "https://pay.kaspi.kz/…",
"expires_at": "2026-09-08T00:37:24Z",
"comment": "Заказ №42",
"created_at": "2026-09-08T00:32:24Z"
}qr_token — ссылка вида pay.kaspi.kz/…; отрисуйте её QR-кодом или дайте клиенту как ссылку. Оплата ловится автоматически — статус уедет в paid, см. журнал событий.
Счёт на номер телефона.
Отправляет клиенту счёт в Kaspi по номеру. Требует инструмент create_invoice.
| Поле | Описание | |
|---|---|---|
phone | req | Номер клиента в Kaspi, напр. +77011234567. |
amount | req | Сумма, целые тенге. |
comment, metadata | opt | Как у QR. |
curl -X POST https://api.aitole.kz/v2/invoices \ -H "X-API-Key: sk_live_…" -H "Idempotency-Key: inv-42" \ -d '{"phone":"+77011234567","amount":12000}' ← 200 { "id":"op_…", "type":"invoice", "status":"pending", "amount":12000 }
Идемпотентность.
Каждая мутация (/v2/qr, /v2/invoices, отмена) требует заголовок Idempotency-Key — любую уникальную строку на смысл запроса (например, номер заказа).
- Нет заголовка →
422 idempotency_key_required. - Тот же ключ и то же тело → вернётся тот же ответ, вторая операция не создаётся.
- Тот же ключ и другое тело →
409 idempotency_key_conflict. - Повтор, пока первый ещё выполняется →
429 payment_in_progress.
Песочница: весь путь платежа без денег.
Песочница — это подключение с driver: "mock". Реальный Kaspi не участвует, деньги не двигаются, qr_token возвращается заведомо ненастоящий (mock_qr_…, оплатить его нельзя).
sk_test_…) работает только с песочными подключениями, боевой (sk_live_…) — только с боевыми. Попытка смешать даёт 403 key_mode_mismatch. Это не соглашение, а проверка: взяли тестовый ключ — настоящие деньги списаться не могут.Довести платёж до оплаты
Управляющие ручки меняют состояние на стороне провайдера, а статус вашей операции двигает тот же поллер, что и в бою — с той же задержкой в несколько секунд. Так вы проверяете ровно тот код, который отработает на живых деньгах, включая обработку события.
Отмечает операцию оплаченной. Через несколько секунд она станет paid, в журнале появится payment.paid.
Отмечает операцию просроченной → expired и payment.expired.
«Вытесняет» сессию подключения — так можно проверить свою реакцию на смерть сессии, не ломая боевое подключение. Подключение уйдёт в session_expired с событием connection.session_expired.
# выставили счёт в песочнице curl -X POST https://api.aitole.kz/v2/qr \ -H "X-API-Key: sk_test_…" -H "Idempotency-Key: demo-1" \ -d '{"amount": 5000}' # «оплатили» его curl -X POST https://api.aitole.kz/v2/sandbox/operations/op_…/pay \ -H "X-API-Key: sk_test_…" # через пару секунд статус уехал сам curl https://api.aitole.kz/v2/operations/op_… -H "X-API-Key: sk_test_…" ← { "status": "paid", "paid_at": "…" }
403 sandbox_only: «пометить оплаченным» не должно быть доступно там, где деньги настоящие.Операция по id.
Текущее состояние операции с позициями. Чужая операция или неизвестный id → 404 operation_not_found.
{ "id":"op_…", "type":"qr", "status":"paid", "amount":5000,
"provider_id":"qr:…", "paid_at":"2026-09-08T00:33:10Z",
"comment":"Заказ №42", "items":[], "created_at":"…" }Возможные статусы
created → pending → терминальные paid, expired, cancelled, failed. Переход двигает поллер платформы автоматически.
Список операций.
Курсорная лента, свежие первыми. Ответ: { data, next_cursor, has_more }. Для следующей страницы передайте next_cursor в cursor.
| Query | Описание |
|---|---|
limit | 1–100, по умолчанию 20. |
cursor | Курсор следующей страницы из предыдущего ответа. |
status | Фильтр: pending, paid, expired, cancelled, failed. |
type | qr или invoice. |
date_from, date_to | YYYY-MM-DD, включительно, по времени Астаны (UTC+5). |
curl "https://api.aitole.kz/v2/operations?limit=2&status=paid" \ -H "X-API-Key: sk_live_…" ← 200 { "data":[ … ], "next_cursor":"eyJ0Ijoi…", "has_more":true }
Отмена операции.
Отменяет ещё не оплаченную операцию. Недопустимый переход (например, уже paid) → 409 invalid_state_transition. Требует активного подключения.
curl -X POST https://api.aitole.kz/v2/operations/op_…/cancel \ -H "X-API-Key: sk_live_…" -H "Idempotency-Key: cancel-42" ← 200 { "id":"op_…", "status":"cancelled" }
Возврат — полный и частичный.
Операция остаётся в статусе paid. Отдельного статуса «возвращён» нет намеренно: деньги были получены, а потом возвращены — это два факта, а не замена одного другим, и бухгалтерия должна видеть оба.
{ "operation_id":"op_…",
"amount":2500, // без поля — вернётся ОСТАТОК
"reason":"клиент вернул товар" }amount возвращается остаток, а не изначальная сумма. После частичного возврата «вернуть всё» означает «вернуть то, что ещё не вернули», — иначе повторный вызов пытался бы вернуть больше, чем было получено.{ "id":"…", "operation_id":"op_…",
"amount":2500, "status":"done",
"operation":{ "status":"paid", "amount":10000,
"refunded_amount":2500 } }status (done / failed), а не HTTP-код; причина — в failure_message словами Kaspi.Что нужно знать заранее
- Нужен отдельный инструмент
refundв скоупе ключа. Ключ, выданный кассе на выставление счетов, возвращать деньги не должен — это движение денег в обратную сторону. - Возврат возможен только для
paid. Неоплаченный QR не возвращают: его отменяют или он протухает сам. - Окно возврата — 90 дней с момента оплаты, дальше
refund_window_expired. Это наше ограждение; у Kaspi могут быть свои сроки, и его отказ придёт вfailure_message. Idempotency-Keyобязателен, как и на всех изменяющих запросах. Повтор с тем же ключом вернёт тот же результат, а не второй возврат.
Журнал событий.
Курсорная лента событий аккаунта — payment.created, payment.paid, payment.expired, payment.cancelled, payment.refunded и connection.session_expired. Те же query, что у операций (limit, cursor, type, date_from/to).
connection.session_expired — событие про подключение, а не про платёж (operation_id у него пустой). Означает, что сессия Kaspi перестала признаваться и приём платежей по этому подключению остановлен до повторного онбординга. Это событие стоит отслеживать наравне с платёжными: пока подключение не восстановлено, новые платежи будут отвергаться с no_active_connection.{ "data": [
{ "id":"evt_…", "type":"payment.paid",
"operation_id":"op_…",
"payload":{ "id":"op_…", "status":"paid", "amount":5000 },
"created_at":"2026-09-08T00:33:10Z" }
], "next_cursor":null, "has_more":false }/v2/events остаётся источником правды: он переживает любой сбой доставки.Вебхуки: события приходят сами.
Подписка создаётся только из кабинета, под токеном владельца. API-ключ сюда не пускают вовсе: иначе утёкший ключ интеграции позволял бы увести ваши события на чужой адрес.
Доступные события: payment.created, payment.paid, payment.expired, payment.cancelled, payment.refunded, connection.session_expired, connection.restored, webhook.test. Либо "*" — всё, включая события, добавленные позже. Опечатка в имени отвергается сразу (unknown_event), а не оборачивается молчащим эндпоинтом.
{ "id":"…", "url":"https://shop.kz/aitole",
"events":["payment.paid"], "enabled":true,
"secret":"whsec_…" }Что приходит
POST с телом-конвертом и заголовками X-Webhook-Id, X-Webhook-Event, X-Webhook-Timestamp, X-Webhook-Signature.
{ "id":"evt_…", "type":"payment.paid",
"created_at":"2026-09-09T10:00:00.000Z",
"data":{ "id":"op_…", "status":"paid", "amount":5000 } }Проверка подписи
Заголовок X-Webhook-Signature имеет вид sha256=<hex>; подпись — HMAC-SHA256 от строки "{timestamp}.{сырое тело}". Считайте её по сырым байтам запроса: если сначала распарсить JSON и собрать обратно, порядок ключей изменится и подпись не сойдётся.
const raw = await readRawBody(req); // именно байты, не JSON.parse const ts = req.headers['x-webhook-timestamp']; const got = req.headers['x-webhook-signature']; // вида "sha256=abc…" // префикс sha256= входит в подпись — считаем строку целиком const mine = 'sha256=' + crypto.createHmac('sha256', secret) .update(`${ts}.${raw}`).digest('hex'); // сравнение постоянного времени — не === const a = Buffer.from(mine), b = Buffer.from(got); const ok = a.length === b.length && crypto.timingSafeEqual(a, b); // и обязательно свежесть: иначе перехваченный запрос можно повторить if (!ok || Math.abs(Date.now()/1000 - ts) > 300) return res.status(400).end(); res.status(200).end(); // отвечайте быстро
Повторы и отключение
Успех — любой ответ 2xx. Всё остальное (включая таймаут в 10 секунд) — неудача, и мы повторяем: через 1 мин, 5 мин, 30 мин, 2 ч, 6 ч, 24 ч. Каждая попытка видна в кабинете: код ответа, длительность, текст ошибки.
· Порядок доставки не гарантирован. Повтор
payment.created может прийти после payment.paid. Ориентируйтесь на data.status в конверте, а не на очерёдность.· Доставка возможна дважды. Если ваш ответ не дошёл до нас, мы повторим. Считайте обработчик по
id конверта идемпотентным.· Отвечайте сразу, работайте потом. Долгая обработка внутри запроса упирается в наш таймаут и превращает успешную доставку в повтор.
Если эндпоинт отказывает непрерывно двое суток, мы его отключаем и перестаём слать — иначе очередь забивается мёртвым адресом. Включить обратно можно в кабинете; события за время простоя доступны через /v2/events.
Требования к адресу
Только https и только публичный адрес. localhost, 127.0.0.1, внутренние диапазоны и адрес облачных метаданных отклоняются при создании — и повторно проверяются в момент доставки, поэтому домен, который резолвится во внутреннюю сеть, не поможет. Для локальной разработки используйте туннель.
Кнопка «Проверить» в кабинете шлёт webhook.test — настоящую доставку с настоящей подписью, на тот самый эндпоинт, не дожидаясь платежа.
Лимиты частоты.
Боевые ключи — 200 запросов в минуту. Тестовые (sk_test_…) — без лимита. При превышении — 429 rate_limit_exceeded с заголовком Retry-After.
Тарифные лимиты.
Ограничений два: сколько платежей можно принять за календарный месяц и сколько операций может висеть неоплаченными одновременно.
| Тариф | Оплаченных платежей в месяц |
|---|---|
free | 20 |
start | 500 |
pro | без лимита |
Что именно считается
- Месячная квота — только ОПЛАЧЕННЫЕ платежи. Выставили 50 счетов, оплатили 12 — израсходовано 12. Брошенные и просроченные счета квоту не жгут.
- Месяц календарный, по времени Астаны (UTC+5). Квота обновляется 1-го числа.
- Песочница лимитов не расходует: тестовые ключи считаются отдельно и месячного ограничения не имеют (одновременно активных — 10).
- Уже висящие неоплаченные счета могут дооплатиться сверх квоты — отказывать деньгам, которые уже в пути, платформа не станет.
- Одновременно неоплаченных счетов тоже есть предел — это защита честного использования, а не тарифная опция: каждый висящий счёт платформа опрашивает у Kaspi, и слишком большое их число вредит вашей же сессии. Потолок щедрый, в обычной работе вы его не заметите; текущее значение видно в кабинете.
Отказы
| Код | HTTP | Когда |
|---|---|---|
plan_limit_exceeded | 429 | Месячная квота исчерпана. |
too_many_active_operations | 429 | Слишком много неоплаченных счетов одновременно. |
Текущий тариф и расход видны в кабинете и в ответе GET /me.
Коды ошибок.
| Код | HTTP | Когда |
|---|---|---|
invalid_api_key | 401 | Ключ не передан или недействителен. |
tool_not_allowed | 403 | Инструмент вне scopes ключа. |
amount_over_key_cap | 422 | Сумма выше потолка ключа. |
amount_not_whole | 422 | Сумма не целое положительное число. |
validation_error | 422 | Некорректное тело/параметр запроса. |
idempotency_key_required | 422 | Нет заголовка на мутации. |
idempotency_key_conflict | 409 | Тот же ключ с другим телом. |
payment_in_progress | 429 | Повтор, пока первый запрос выполняется. |
no_active_connection | 409 | Нет активного подключения Kaspi. |
connection_ambiguous | 409 | Активных подключений несколько, основное не задано. Укажите connection_id или привяжите ключ. |
operation_not_found | 404 | Операция не найдена (или чужая). |
invalid_state_transition | 409 | Недопустимый переход статуса (напр. отмена оплаченной). |
connection_not_found | 404 | Подключение не найдено. |
too_many_otp_attempts | 429 | Превышены попытки OTP при онбординге. |
session_expired | 440 | Сессия Kaspi истекла — нужен повторный онбординг. |
key_mode_mismatch | 403 | Ключ и подключение из разных миров: тестовый ключ в боевое подключение или наоборот. |
sandbox_only | 403 | Ручка доступна только тестовым ключам. |
plan_limit_exceeded | 429 | Исчерпана месячная квота тарифа. |
too_many_active_operations | 429 | Слишком много одновременно неоплаченных счетов. |
rate_limit_exceeded | 429 | Превышена частота запросов. |
service_unavailable | 503 | Коннектор недоступен или не ответил за таймаут. |
Health.
Проверка доступности — без ключа. Годится для аптайм-мониторинга.
{ "status":"ok", "checks":{ "database":"ok", "redis":"ok" } }Вопрос по интеграции — приложите X-Request-Id из ответа.