Kaspi Pay API на практике: QR, счёт на номер и ссылки на оплату — curl, Python и Node.js
Три вопроса, с которых начинается почти любая интеграция 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 httpx
import os, uuid, httpx
api = httpx.Client(
base_url="https://api.aitole.kz",
headers={"X-API-Key": os.environ["AITOLE_KEY"]},
timeout=15,
)
// Node.js 18+ — встроенный fetch
const 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 time
def 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 напрямую или через другого посредника — на чём споткнулись? Самые интересные случаи разберём в продолжении.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.