ESPNNHL Tonight: Previews, stats and betting intel for Thursday's dozen gamesThe Jerusalem PostIran's floating oil reserves, used for China sales, completely depleted, analysts say - reportPunchOsun APC rejects short notice for Tinubu campaign councilBollywood HungamaEXCLUSIVE: Ramayana's South distributors locked; Dil Raju bags Andhra-Telangana, Yash takes Karnataka, Think Studios gets Tamil Nadu, E4 Entertainment acquires KeralaDaily MaverickBusiness Reflection: Loaded for Bear: MTBPS shines light on lost revenue from missed mining opportunitiesRTP DesportoProva de Supertubos suspensaInquirerEl Niño response must draw on scientific, historical data – expertZDF heuteEntdecken Sie das ZDF-NachrichtenstudioAntara NewsGovt taps SEZ-integrated vocational training for industry alignmentWirtualna PolskaSikorski uderza w prezydenta. "Nie ma na to zgody"SCMP ChinaChina urges opening Bab al-Mandeb in first joint statement with regional nationESPN CricinfoSamson, Iyer and Dhir take India home in nervy chase against NZ
The Daily Newsstand · Free, Always
Thursday, October 22, 2026

Kaspi Pay API на практике: QR, счёт на номер и ссылки на оплату — curl, Python и Node.js

Translate

Три вопроса, с которых начинается почти любая интеграция 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 любят разворачивать превью, поисковые боты ходят по страницам — лимиты от этого не тратятся. Операция появляется только по нажатию «Оплатить».

  • Ссылку можно изменить или выключить; выключенная открывается как «не найдена». Удобно, когда закончилась акция или закрыт набор.

  • По каждой ссылке ведётся статистика: сколько оплат и на какую сумму.

Как узнать, что оплатили

Подробно это разобрано в статье про бота, здесь — короткая выжимка.

  1. Вебхук — для скорости. Тело — конверт {"id": "evt_…", "type": "payment.paid", "created_at": "…", "data": {…}}, подпись HMAC-SHA256 в X-Webhook-Signature: sha256=<hex> от строки "{timestamp}.{сырое тело}". Неудачная доставка повторяется от 1 минуты до 24 часов, порядок не гарантирован — дедуплицируйте по id конверта и смотрите на data.status.

  2. Журнал GET /v2/events — для надёжности. Хранит историю событий, читается с курсором и переживает любой сбой доставки: упал сервер на ночь — утром дочитали журнал с последнего курсора.

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

Что делать

transient

повторить с тем же Idempotency-Key, с паузой

rate_limit

подождать Retry-After, потом повторить

auth

не повторять: проблема с ключом или правами

permanent

не повторять: исправить запрос или показать пользователю

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). Касса для запроса выбирается так:

  1. connection_id из запроса;

  2. касса, к которой привязан ключ;

  3. основная касса;

  4. единственная активная.

Если выбрать однозначно нельзя — 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 на сумму заказа

POST /v2/qr

покупатель на вашем сайте, в боте, у кассы

Счёт в приложение Kaspi

POST /v2/invoices

есть номер телефона, покупатель не у экрана

Постоянная ссылка

POST /v2/payment-links

соцсети, лендинги, каталог услуг

Узнать об оплате

вебхук + GET /v2/events

всегда оба

Проверить одну операцию

GET /v2/operations/{id}

перед выдачей товара

Полный справочник с остальными полями — aitole.kz/docs; песочница работает с тестовым ключом, без Kaspi и без денег.

Какой сценарий у вас основной — QR, счёт на номер или ссылка? И если вы уже интегрировали Kaspi напрямую или через другого посредника — на чём споткнулись? Самые интересные случаи разберём в продолжении.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.