Три вопроса, с которых начинается почти любая интеграция Kaspi в Казахстане: как выставить покупателю QR на сумму заказа, как отправить счёт прямо в его приложение Kaspi по номеру телефона и как сделать постоянную ссылку «Оплатить», которую не нужно перевыпускать каждые пять минут. Ниже — все три сценария с запросами на curl, Python и Node.js, обработка ошибок, несколько касс и настройка ключей перед продакшеном.
В прошлой статье мы собирали Telegram-бота и подробно разбирали вебхуки: подпись, повторы, идемпотентность, сверку. Здесь фокус шире — сам API, без привязки к боту.
Примеры написаны на REST API Aitole — сервиса, который выставляет счета через Kaspi-аккаунт самого продавца. Автор участвует в его разработке; это независимый сервис, не банк и не партнёр АО «Kaspi Bank». У другого посредника будут другие URL и поля, но вопросы, которые придётся решить, те же.
Модель: операция и её статусы
Каждое выставление — QR или счёт на номер — создаёт операцию op_…. Её жизненный путь:
pending ──▶ paid │ ├──▶ expired (QR не оплатили за 5 минут) └──▶ failed (Kaspi отказал при выставлении)
Две вещи, которые полезно держать в голове с самого начала:
-
Деньги идут напрямую на Kaspi продавца. Сервис работает через штатную роль «Кассир» в Kaspi Pay: она умеет выставлять счета, но не может выводить деньги, смотреть баланс организации и менять настройки. Для API это значит одно: вы управляете счетами, а не деньгами.
-
Ответ на создание — не подтверждение оплаты. Он возвращает операцию в
pending. Об оплате вы узнаёте отдельно: вебхуком, журналом событий или запросом статуса.
Подготовка
Базовый адрес — https://api.aitole.kz. Авторизация — заголовок X-API-Key. Для разработки берите тестовый ключ sk_test_…: он работает только с песочницей (mock-подключение вместо Kaspi, безлимит запросов, настоящие деньги не двигаются).
export AITOLE_KEY=sk_test_...
Общий клиент, который понадобится дальше:
# Python 3.10+, pip install httpximport os, uuid, httpxapi = httpx.Client( base_url="https://api.aitole.kz", headers={"X-API-Key": os.environ["AITOLE_KEY"]}, timeout=15,)
// Node.js 18+ — встроенный fetchconst BASE = "https://api.aitole.kz";const KEY = process.env.AITOLE_KEY;async function call(method, path, body, idemKey) { const headers = { "X-API-Key": KEY, "Content-Type": "application/json" }; if (idemKey) headers["Idempotency-Key"] = idemKey; const r = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const data = await r.json(); if (!r.ok) throw Object.assign(new Error(data.error.message), { status: r.status, ...data.error }); return data;}
Сценарий 1. QR на сумму заказа
Классика интернет-магазина: покупатель оформил заказ на 5 000 ₸ — показываем QR ровно на эту сумму.
curl -X POST https://api.aitole.kz/v2/qr \ -H "X-API-Key: $AITOLE_KEY" \ -H "Idempotency-Key: shop-1024-1" \ -H "Content-Type: application/json" \ -d '{"amount": 5000, "comment": "Заказ №1024"}'
def create_qr(order_id: str, amount: int, attempt: int = 1) -> dict: r = api.post( "/v2/qr", headers={"Idempotency-Key": f"shop-{order_id}-{attempt}"}, json={"amount": amount, "comment": f"Заказ №{order_id}"}, ) r.raise_for_status() op = r.json() if op["status"] == "failed": # отказ приходит с HTTP 200 raise RuntimeError(op.get("failure_message") or "Kaspi отказал") return op # op["id"], op["qr_token"], op["expires_at"]
const op = await call("POST", "/v2/qr", { amount: 5000, comment: "Заказ №1024" }, "shop-1024-1");if (op.status === "failed") throw new Error(op.failure_message || "Kaspi отказал");
Что важно:
-
Idempotency-Keyобязателен на каждом создании. Повтор запроса с тем же ключом вернёт ту же операцию, а не второй счёт — это спасает от двойных QR при таймаутах и двойных кликах. Номер попытки в ключе нужен, чтобы после истечения выставить новый QR. -
HTTP 200 ещё не успех. Если Kaspi отказал, код ответа 200, а в теле
status: "failed"и причина вfailure_message. Одногоraise_for_status()мало. -
qr_token— это ссылкаpay.kaspi.kz. С телефона её удобно открыть кнопкой (сразу откроется Kaspi), на компьютере — нарисовать из неё QR-картинку. -
QR живёт 5 минут. Не кешируйте его и не печатайте на листовках. Для «вечного» QR есть сценарий 3.
Сценарий 2. Счёт на номер телефона
Иногда QR неудобен: заказ принимают по телефону, в WhatsApp или менеджер оформляет его в CRM. Тогда счёт можно отправить прямо в приложение Kaspi покупателя по номеру.
curl -X POST https://api.aitole.kz/v2/invoices \ -H "X-API-Key: $AITOLE_KEY" \ -H "Idempotency-Key: crm-1025-1" \ -H "Content-Type: application/json" \ -d '{"amount": 12000, "phone": "+77001234567", "comment": "Заказ №1025"}'
def create_invoice(order_id: str, amount: int, phone: str) -> dict: r = api.post( "/v2/invoices", headers={"Idempotency-Key": f"crm-{order_id}-1"}, json={"amount": amount, "phone": phone, "comment": f"Заказ №{order_id}"}, ) if r.status_code >= 400: err = r.json()["error"] if err["code"] == "payer_not_kaspi_client": return {"fallback": "qr"} # номер не в Kaspi — предложите QR raise RuntimeError(f'{err["code"]}: {err["message"]}') return r.json()
try { const inv = await call("POST", "/v2/invoices", { amount: 12000, phone: "+77001234567", comment: "Заказ №1025" }, "crm-1025-1");} catch (e) { if (e.code === "payer_not_kaspi_client") { /* предложить QR или ссылку */ } else throw e;}
Номер — в формате E.164 (+7…). Если номер не принадлежит клиенту Kaspi, запрос вернёт ошибку payer_not_kaspi_client. Это нормальный бизнес-сценарий, а не сбой: обработайте его отдельно и предложите покупателю QR или ссылку на оплату.
QR или счёт на номер? Короткое правило: покупатель смотрит на ваш экран (сайт, бот, касса) — QR. Покупатель где-то ещё, а у вас есть его номер — счёт на номер. Отдельный плюс счёта: вопрос «кто заплатил» не возникает вообще — плательщик известен заранее.
Сценарий 3. Постоянная ссылка на оплату
QR живёт 5 минут, а продавцу часто нужна одна ссылка «навсегда»: в шапку Instagram, в закреп Telegram-канала, на кнопку лендинга. Решение — не QR, а страница: клиент открывает aitole.kz/pay/<название>, жмёт «Оплатить», и в этот момент создаётся свежий QR. На телефоне сразу открывается Kaspi, на компьютере показывается QR.
Такие ссылки создаются в кабинете без кода, но для каталога из сотни услуг удобнее API:
curl -X POST https://api.aitole.kz/v2/payment-links \ -H "X-API-Key: $AITOLE_KEY" \ -H "Idempotency-Key: link-konsultaciya" \ -H "Content-Type: application/json" \ -d '{"title": "Консультация", "amount": 4990, "merchant_name": "Психолог Айжан", "slug": "konsultaciya"}'
def create_link(slug: str, title: str, amount: int) -> dict: r = api.post( "/v2/payment-links", headers={"Idempotency-Key": f"link-{slug}"}, json={"title": title, "amount": amount, "slug": slug}, ) if r.status_code == 409 and r.json()["error"]["code"] == "slug_taken": return create_link(f"{slug}-{uuid.uuid4().hex[:4]}", title, amount) r.raise_for_status() return r.json()
Полезные поля и поведение:
-
slug— свой адрес страницы. Если он занят, придёт409 slug_taken. -
ask_name(по умолчаниюtrue) — спрашивать имя плательщика. Оно потом видно в кабинете какpayer_name— по QR сам Kaspi не сообщает, кто заплатил. -
min_amount/max_amount— для ссылок без фиксированной суммы (детали — в справочнике). -
Открытие и превью ссылки ничего не создают. Telegram и WhatsApp любят разворачивать превью, поисковые боты ходят по страницам — лимиты от этого не тратятся. Операция появляется только по нажатию «Оплатить».
-
Ссылку можно изменить или выключить; выключенная открывается как «не найдена». Удобно, когда закончилась акция или закрыт набор.
-
По каждой ссылке ведётся статистика: сколько оплат и на какую сумму.
Как узнать, что оплатили
Подробно это разобрано в статье про бота, здесь — короткая выжимка.
-
Вебхук — для скорости. Тело — конверт
{"id": "evt_…", "type": "payment.paid", "created_at": "…", "data": {…}}, подпись HMAC-SHA256 вX-Webhook-Signature: sha256=<hex>от строки"{timestamp}.{сырое тело}". Неудачная доставка повторяется от 1 минуты до 24 часов, порядок не гарантирован — дедуплицируйте поidконверта и смотрите наdata.status. -
Журнал
GET /v2/events— для надёжности. Хранит историю событий, читается с курсором и переживает любой сбой доставки: упал сервер на ночь — утром дочитали журнал с последнего курсора. -
GET /v2/operations/{id}— точечная проверка одной операции, например перед выдачей дорогого товара.
def is_paid(op_id: str) -> bool: r = api.get(f"/v2/operations/{op_id}") r.raise_for_status() return r.json()["status"] == "paid"
Рабочая схема — вебхук плюс журнал или периодическая сверка. Один вебхук без страховки рано или поздно потеряет оплату.
Ошибки: смотрите на категорию, а не на текст
Ошибка приходит объектом:
{ "error": { "code": "payer_not_kaspi_client", "message": "…", "category": "permanent", "request_id": "…" }}
Поле category — главное для кода повторов:
|
category |
Что делать |
|---|---|
|
|
повторить с тем же |
|
|
подождать |
|
|
не повторять: проблема с ключом или правами |
|
|
не повторять: исправить запрос или показать пользователю |
import timedef with_retries(fn, attempts: int = 4): for i in range(attempts): r = fn() if r.status_code < 400: return r err = r.json()["error"] if err["category"] == "rate_limit": time.sleep(int(r.headers.get("Retry-After", "1"))) elif err["category"] == "transient": time.sleep(2 ** i) else: raise RuntimeError(f'{err["code"]} (request_id={err["request_id"]})') raise RuntimeError("retries exhausted")
Повтор безопасен именно потому, что Idempotency-Key тот же: создать второй счёт таким повтором нельзя. request_id логируйте всегда — с ним проще разбираться с поддержкой. Лимит боевых ключей — 200 запросов в минуту.
Несколько касс
Если у продавца несколько юрлиц или точек, у него несколько касс (подключений Kaspi). Касса для запроса выбирается так:
-
connection_idиз запроса; -
касса, к которой привязан ключ;
-
основная касса;
-
единственная активная.
Если выбрать однозначно нельзя — 409 connection_ambiguous; если ключ привязан к другой кассе — 403 forbidden. Каждое событие оплаты несёт connection_id, так что в обработчике вебхука вы всегда знаете, на какую точку пришли деньги.
Практичный вариант для сети точек — отдельный ключ на каждую кассу. Тогда код точки физически не может выставить счёт от имени соседней, и connection_id в запросах вообще не нужен.
Перед продакшеном
-
Боевой ключ — с минимальными правами. Если сервис только выставляет QR, оставьте ему только
create_qrи задайте потолок суммы. Утёкший ключ тогда не выставит счёт на миллион и не сделает ничего лишнего. -
Тестовый и боевой ключи не перепутать.
sk_test_работает только с песочницей, с настоящим Kaspi его не пустит. Случайно двинуть живые деньги из тестов нельзя. -
Номер кассира — выделенный. У Kaspi одна активная сессия на номер: если войти в приложение Kaspi Pay под номером кассира, подключение отвалится и новые счета перестанут выставляться. Подпишитесь на событие
connection.session_expiredи шлите его в чат дежурного: мониторинг «сервис отвечает» здесь ничего не покажет, нужен мониторинг «деньги принимаются». Восстановление — повторный вход по SMS. -
Суммы — целые тенге. Никаких float.
-
Фискальный чек — отдельно. Его пробивает ваша онлайн-касса, а не платёжный API.
Чего в API нет
Честно о границах, чтобы не искать то, чего не существует:
-
Автосписаний нет — это ограничение самого Kaspi. Для абонементов есть регулярные счета
/v2/subscriptions(раз в неделю, месяц или квартал): сервис сам выставляет счёт, повторяет неоплаченный и ведёт льготный период, а клиент каждый раз оплачивает в одно касание. Это тема следующей статьи. -
Деньги не проходят через API. Нет баланса, выводов и эквайринга — только счета на Kaspi продавца.
-
MCP-сервера для AI-агентов пока нет.
Итого
|
Задача |
Эндпоинт |
Когда |
|---|---|---|
|
QR на сумму заказа |
|
покупатель на вашем сайте, в боте, у кассы |
|
Счёт в приложение Kaspi |
|
есть номер телефона, покупатель не у экрана |
|
Постоянная ссылка |
|
соцсети, лендинги, каталог услуг |
|
Узнать об оплате |
вебхук + |
всегда оба |
|
Проверить одну операцию |
|
перед выдачей товара |
Полный справочник с остальными полями — aitole.kz/docs; песочница работает с тестовым ключом, без Kaspi и без денег.
Какой сценарий у вас основной — QR, счёт на номер или ссылка? И если вы уже интегрировали Kaspi напрямую или через другого посредника — на чём споткнулись? Самые интересные случаи разберём в продолжении.
ссылка на оригинал статьи https://habr.com/ru/articles/1097790/