Aitole API v2
Ошибки Статус
Aitole API

Приём Kaspi Pay по REST — над аккаунтом мерчанта.

Один REST-API, чтобы выставлять QR и счета на номер, ловить статус и читать журнал событий. Платформа держит подключение к Kaspi за вас; вы делаете HTTP-запросы.

Базовый адрес

base url
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_… (тестовый, безлимит по частоте).

header
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) в кабинет не пускают вовсе.

POST/auth/login
curl
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, не по тексту. Полный список кодов — внизу.

json
{
  "error": {
    "code": "amount_over_key_cap",
    "message": "Сумма операции превышает потолок ключа",
    "category": "validation",
    "request_id": "req_79b6…"
  }
}
Подключение

Онбординг Kaspi — из кабинета, по SMS.

Подключение аккаунта мерчанта — три auth-шага под JWT (owner/admin). Секрет сессии платформа хранит зашифрованным и наружу не отдаёт. Все ручки — за Authorization: Bearer.

POST/me/connectionsjwt

Создаёт черновик подключения. Тело: { "driver": "http" } для боевого Kaspi или { "driver": "mock" } для песочницы. Ответ — безопасный вид без секрета сессии.

json
{ "id": "7a3069ab-…", "driver": "http",
  "status": "onboarding", "is_default": false, "created_at": "2026-09-08T…" }

Затем — три шага авторизации по :id

ШагТелоОтвет (stage)
POST /me/connections/:id/auth/initphone_required
POST /me/connections/:id/auth/phone{"phone":"+7701…"}otp_required
POST /me/connections/:id/auth/otp{"code":"1234"}active
Нужен номер кассира, а не владельца. Подключайте номер аккаунта с ролью «Кассир» в Kaspi Pay. У Kaspi одна активная сессия на номер: пока номер привязан к платформе, входить под ним в приложение Kaspi Pay нельзя — сессия платформы будет вытеснена и приём платежей встанет. Номер передавайте в E.164 (+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/initphoneotp).
logged_outОтключено вами через POST /:id/logout.

Как это выглядит с вашей стороны

  • Новые платежи отвергаются с 409 no_active_connection.
  • В /v2/events появляется connection.session_expired.
  • GET /me/connections показывает статус подключения.
Проверить состояние в любой момент можно вручную: GET /me/connections/:id/health. Восстановление — те же три auth-шага на существующем подключении.

Несколько Kaspi-аккаунтов

У одного аккаунта платформы может быть несколько боевых подключений — например, по одному на юрлицо или на точку. Касса для платежа выбирается в таком порядке:

  1. connection_id в теле запроса — явный выбор.
  2. Подключение, к которому привязан ключ. Привязка задаётся при выпуске ключа в кабинете.
  3. Подключение с пометкой «основное».
  4. Единственное активное подключение своего контура.
Привязка ключа — граница, а не умолчание. Ключ, выпущенный для кассы A, не выставит счёт на кассу B, даже если передать её 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.

POST/v2/qrapi-keyIdempotency-Key

Выставляет QR-платёж. Требует инструмент create_qr в scopes ключа и обязательный заголовок Idempotency-Key.

Параметры тела

ПолеОписание
amountreqСумма, целые тенге.
commentoptКомментарий к платежу.
itemsoptПозиции чека: [{ "name", "amount" }].
metadataoptВаш произвольный JSON — вернётся в операции и событиях.
curl
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"}'
json · 200
{
  "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, см. журнал событий.

Приём платежей

Счёт на номер телефона.

POST/v2/invoicesapi-keyIdempotency-Key

Отправляет клиенту счёт в Kaspi по номеру. Требует инструмент create_invoice.

ПолеОписание
phonereqНомер клиента в Kaspi, напр. +77011234567.
amountreqСумма, целые тенге.
comment, metadataoptКак у QR.
curl
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.
Ключ уникален в пределах вашего аккаунта и живёт 24 часа. Безопасно повторять запрос при сетевом таймауте — дубля платежа не будет.
Песочница

Песочница: весь путь платежа без денег.

Песочница — это подключение с driver: "mock". Реальный Kaspi не участвует, деньги не двигаются, qr_token возвращается заведомо ненастоящий (mock_qr_…, оплатить его нельзя).

Граница гарантирована ключом. Тестовый ключ (sk_test_…) работает только с песочными подключениями, боевой (sk_live_…) — только с боевыми. Попытка смешать даёт 403 key_mode_mismatch. Это не соглашение, а проверка: взяли тестовый ключ — настоящие деньги списаться не могут.

Довести платёж до оплаты

Управляющие ручки меняют состояние на стороне провайдера, а статус вашей операции двигает тот же поллер, что и в бою — с той же задержкой в несколько секунд. Так вы проверяете ровно тот код, который отработает на живых деньгах, включая обработку события.

POST/v2/sandbox/operations/:id/paysk_test_

Отмечает операцию оплаченной. Через несколько секунд она станет paid, в журнале появится payment.paid.

POST/v2/sandbox/operations/:id/expiresk_test_

Отмечает операцию просроченной → expired и payment.expired.

POST/v2/sandbox/connections/:id/kill-sessionsk_test_

«Вытесняет» сессию подключения — так можно проверить свою реакцию на смерть сессии, не ломая боевое подключение. Подключение уйдёт в session_expired с событием connection.session_expired.

curl
# выставили счёт в песочнице
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.

GET/v2/operations/:idapi-key

Текущее состояние операции с позициями. Чужая операция или неизвестный id → 404 operation_not_found.

json · 200
{ "id":"op_…", "type":"qr", "status":"paid", "amount":5000,
  "provider_id":"qr:…", "paid_at":"2026-09-08T00:33:10Z",
  "comment":"Заказ №42", "items":[], "created_at":"…" }

Возможные статусы

createdpending → терминальные paid, expired, cancelled, failed. Переход двигает поллер платформы автоматически.

Чтение

Список операций.

GET/v2/operationsapi-key

Курсорная лента, свежие первыми. Ответ: { data, next_cursor, has_more }. Для следующей страницы передайте next_cursor в cursor.

QueryОписание
limit1–100, по умолчанию 20.
cursorКурсор следующей страницы из предыдущего ответа.
statusФильтр: pending, paid, expired, cancelled, failed.
typeqr или invoice.
date_from, date_toYYYY-MM-DD, включительно, по времени Астаны (UTC+5).
curl
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 }
Чтение

Отмена операции.

POST/v2/operations/:id/cancelapi-keyIdempotency-Key

Отменяет ещё не оплаченную операцию. Недопустимый переход (например, уже paid) → 409 invalid_state_transition. Требует активного подключения.

curl
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" }
Приём платежей

Возврат — полный и частичный.

POST/v2/refundsapi-key

Операция остаётся в статусе paid. Отдельного статуса «возвращён» нет намеренно: деньги были получены, а потом возвращены — это два факта, а не замена одного другим, и бухгалтерия должна видеть оба.

json · запрос
{ "operation_id":"op_…",
  "amount":2500,          // без поля — вернётся ОСТАТОК
  "reason":"клиент вернул товар" }
Без amount возвращается остаток, а не изначальная сумма. После частичного возврата «вернуть всё» означает «вернуть то, что ещё не вернули», — иначе повторный вызов пытался бы вернуть больше, чем было получено.
json · 200
{ "id":"…", "operation_id":"op_…",
  "amount":2500, "status":"done",
  "operation":{ "status":"paid", "amount":10000,
                "refunded_amount":2500 } }
Отказ приходит с кодом 200, а не 4xx/5xx. То же правило, что и на создании платежа: провайдер отказал — это деловой ответ, а не сбой нашего сервиса. Разбирайте поле status (done / failed), а не HTTP-код; причина — в failure_message словами Kaspi.

Что нужно знать заранее

  • Нужен отдельный инструмент refund в скоупе ключа. Ключ, выданный кассе на выставление счетов, возвращать деньги не должен — это движение денег в обратную сторону.
  • Возврат возможен только для paid. Неоплаченный QR не возвращают: его отменяют или он протухает сам.
  • Окно возврата — 90 дней с момента оплаты, дальше refund_window_expired. Это наше ограждение; у Kaspi могут быть свои сроки, и его отказ придёт в failure_message.
  • Idempotency-Key обязателен, как и на всех изменяющих запросах. Повтор с тем же ключом вернёт тот же результат, а не второй возврат.
Чтение

Журнал событий.

GET/v2/eventsapi-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.
json · 200
{ "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 }
Не хотите опрашивать? Те же события приходят пушем на ваш URL — см. Вебхуки. Опрос /v2/events остаётся источником правды: он переживает любой сбой доставки.
Чтение

Вебхуки: события приходят сами.

POST/me/webhooksjwt

Подписка создаётся только из кабинета, под токеном владельца. API-ключ сюда не пускают вовсе: иначе утёкший ключ интеграции позволял бы увести ваши события на чужой адрес.

Доступные события: payment.created, payment.paid, payment.expired, payment.cancelled, payment.refunded, connection.session_expired, connection.restored, webhook.test. Либо "*" — всё, включая события, добавленные позже. Опечатка в имени отвергается сразу (unknown_event), а не оборачивается молчащим эндпоинтом.

json · 201
{ "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.

json
{ "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 и собрать обратно, порядок ключей изменится и подпись не сойдётся.

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

Справка

Тарифные лимиты.

Ограничений два: сколько платежей можно принять за календарный месяц и сколько операций может висеть неоплаченными одновременно.

ТарифОплаченных платежей в месяц
free20
start500
proбез лимита

Что именно считается

  • Месячная квота — только ОПЛАЧЕННЫЕ платежи. Выставили 50 счетов, оплатили 12 — израсходовано 12. Брошенные и просроченные счета квоту не жгут.
  • Месяц календарный, по времени Астаны (UTC+5). Квота обновляется 1-го числа.
  • Песочница лимитов не расходует: тестовые ключи считаются отдельно и месячного ограничения не имеют (одновременно активных — 10).
  • Уже висящие неоплаченные счета могут дооплатиться сверх квоты — отказывать деньгам, которые уже в пути, платформа не станет.
  • Одновременно неоплаченных счетов тоже есть предел — это защита честного использования, а не тарифная опция: каждый висящий счёт платформа опрашивает у Kaspi, и слишком большое их число вредит вашей же сессии. Потолок щедрый, в обычной работе вы его не заметите; текущее значение видно в кабинете.

Отказы

КодHTTPКогда
plan_limit_exceeded429Месячная квота исчерпана.
too_many_active_operations429Слишком много неоплаченных счетов одновременно.

Текущий тариф и расход видны в кабинете и в ответе GET /me.

Справка

Коды ошибок.

КодHTTPКогда
invalid_api_key401Ключ не передан или недействителен.
tool_not_allowed403Инструмент вне scopes ключа.
amount_over_key_cap422Сумма выше потолка ключа.
amount_not_whole422Сумма не целое положительное число.
validation_error422Некорректное тело/параметр запроса.
idempotency_key_required422Нет заголовка на мутации.
idempotency_key_conflict409Тот же ключ с другим телом.
payment_in_progress429Повтор, пока первый запрос выполняется.
no_active_connection409Нет активного подключения Kaspi.
connection_ambiguous409Активных подключений несколько, основное не задано. Укажите connection_id или привяжите ключ.
operation_not_found404Операция не найдена (или чужая).
invalid_state_transition409Недопустимый переход статуса (напр. отмена оплаченной).
connection_not_found404Подключение не найдено.
too_many_otp_attempts429Превышены попытки OTP при онбординге.
session_expired440Сессия Kaspi истекла — нужен повторный онбординг.
key_mode_mismatch403Ключ и подключение из разных миров: тестовый ключ в боевое подключение или наоборот.
sandbox_only403Ручка доступна только тестовым ключам.
plan_limit_exceeded429Исчерпана месячная квота тарифа.
too_many_active_operations429Слишком много одновременно неоплаченных счетов.
rate_limit_exceeded429Превышена частота запросов.
service_unavailable503Коннектор недоступен или не ответил за таймаут.
Справка

Health.

GET/v2/statuspublic

Проверка доступности — без ключа. Годится для аптайм-мониторинга.

json · 200
{ "status":"ok", "checks":{ "database":"ok", "redis":"ok" } }

Вопрос по интеграции — приложите X-Request-Id из ответа.