Как принимать оплату Kaspi в Telegram-боте?

Обновлено 6 июля 2026 · Решение проблем · Версия в Markdown
Содержание
  1. Архитектура: кто кому что шлёт
  2. Полный файл: Python + aiogram 3.x
  3. Короткая альтернатива: Node.js + grammY
  4. Как протестировать без реальных денег
  5. Типичные ошибки
  6. Вопросы и ответы

Архитектура: кто кому что шлёт

Покупатель ──/start──▶ Telegram-бот
Бот ──POST /invoices (X-API-Key)──▶ ApiPay      ← ключ храним ТОЛЬКО на сервере
ApiPay ──push-счёт──▶ приложение Kaspi покупателя  ← оплата в 1 касание
ApiPay ──POST вебхук (X-Webhook-Signature)──▶ ваш HTTPS-endpoint
Endpoint ──sendMessage──▶ чат покупателя: «Оплата получена»

Единственная хитрость рецепта — связать вебхук с чатом. Мы кладём chat_id внутрь external_order_id при создании счёта: ApiPay вернёт это поле в каждом вебхуке по счёту, и обработчик узнает, кому писать. Для продакшена надёжнее хранить соответствие «счёт → чат» в своей БД, но паттерн с external_order_id работает и без неё.

Полный файл: Python + aiogram 3.x

Один процесс: aiogram (long polling — вебхук Telegram не нужен) + aiohttp-сервер для вебхука ApiPay.

"""
Kaspi-оплата в Telegram-боте через ApiPay. Один файл, ~80 строк.
Установка:  pip install aiogram aiohttp
Запуск:     BOT_TOKEN=... APIPAY_API_KEY=... APIPAY_WEBHOOK_SECRET=... python bot.py
Вебхук ApiPay: https://ваш-домен/webhooks/apipay  (локально — через ngrok http 8080)
"""
import asyncio, hashlib, hmac, json, os, time

import aiohttp
from aiohttp import web
from aiogram import Bot, Dispatcher, F
from aiogram.filters import CommandStart
from aiogram.types import KeyboardButton, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove

BOT_TOKEN = os.environ["BOT_TOKEN"]
APIPAY_API_KEY = os.environ["APIPAY_API_KEY"]        # кабинет → Настройки → API-ключи (показывается 1 раз)
WEBHOOK_SECRET = os.environ["APIPAY_WEBHOOK_SECRET"]  # кнопка «Сгенерировать подпись» у того же ключа
APIPAY_BASE = "https://api.apipay.kz/api/v1"
PRICE = 5000  # тенге; в реальном боте — из вашей корзины/заказа

bot = Bot(BOT_TOKEN)
dp = Dispatcher()
processed: set[tuple] = set()  # дедуп вебхуков; в проде замените на таблицу в БД


@dp.message(CommandStart())
async def start(msg: Message):
    kb = ReplyKeyboardMarkup(
        keyboard=[[KeyboardButton(text="📱 Отправить мой номер", request_contact=True)]],
        resize_keyboard=True,
    )
    await msg.answer("Оплата через Kaspi. Поделитесь номером, на который придёт счёт:", reply_markup=kb)


@dp.message(F.contact)
async def create_invoice(msg: Message):
    # Telegram отдаёт номер как +7700... или 7700...; ApiPay ждёт 8XXXXXXXXXX (11 цифр)
    phone = "8" + "".join(ch for ch in msg.contact.phone_number if ch.isdigit())[-10:]
    order_id = f"tg-{msg.chat.id}-{int(time.time())}"  # chat_id внутри — маршрутизация вебхука

    payload = {
        "phone_number": phone,
        "amount": PRICE,
        "description": "Оплата заказа в Telegram-боте",
        "external_order_id": order_id,
        "external_order_id_idempotency": order_id,  # защита от дублей: повтор запроса даст 409
    }
    async with aiohttp.ClientSession() as s:
        async with s.post(f"{APIPAY_BASE}/invoices", json=payload,
                          headers={"X-API-Key": APIPAY_API_KEY}) as r:
            data = await r.json()
            if r.status != 201:
                await msg.answer(f"Не удалось выставить счёт: {data.get('message', r.status)}")
                return
    # 201 со status=processing — это норма: выставление в Kaspi асинхронное
    await msg.answer(
        "Счёт отправлен! Откройте приложение Kaspi — там уже push с кнопкой «Оплатить». "
        "Счёт действует 24 часа. Как только оплатите — я напишу сюда.",
        reply_markup=ReplyKeyboardRemove(),
    )


async def apipay_webhook(request: web.Request):
    raw = await request.read()  # ВАЖНО: подпись считается по СЫРОМУ телу, до JSON-парсинга
    expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
        return web.Response(status=401)

    event = json.loads(raw)
    if event.get("event") == "invoice.status_changed":
        inv = event["invoice"]
        key = (inv["id"], inv["status"])                 # дедуп: один переход обрабатываем один раз
        order = inv.get("external_order_id") or ""
        if key not in processed and order.startswith("tg-"):
            processed.add(key)
            chat_id = int(order.split("-")[1])
            if inv["status"] == "paid":
                sandbox = " (тест)" if inv.get("is_sandbox") else ""
                await bot.send_message(chat_id, f"✅ Оплата получена: {inv['amount']} ₸{sandbox}. Спасибо!")
            elif inv["status"] in ("cancelled", "expired", "error"):
                # cancelled/expired могут позже смениться на paid (оплата выиграла гонку) — придёт ещё вебхук
                await bot.send_message(chat_id, "Счёт не оплачен (отменён или истёк). /start — выставлю новый.")
    return web.Response(status=200)  # отвечаем 2xx быстрее 5 секунд; тяжёлое — в фон


async def main():
    app = web.Application()
    app.router.add_post("/webhooks/apipay", apipay_webhook)
    runner = web.AppRunner(app)
    await runner.setup()
    await web.TCPSite(runner, "0.0.0.0", 8080).start()   # снаружи — HTTPS (nginx/ngrok)
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Что осталось сделать руками: вписать https://ваш-домен/webhooks/apipay в кабинет (Настройки → API-ключи → webhook_url) и нажать «Сгенерировать подпись» — это и есть APIPAY_WEBHOOK_SECRET. Проверьте кнопкой «Проверить»: придёт событие webhook.test (наш обработчик его просто проигнорирует и ответит 200 — этого достаточно).

Короткая альтернатива: Node.js + grammY

// npm i grammy express  (Node 18+: fetch встроен)
const { Bot } = require("grammy");
const express = require("express");
const crypto = require("crypto");

const bot = new Bot(process.env.BOT_TOKEN);
const processed = new Set();

bot.command("start", (ctx) => ctx.reply("Пришлите номер Kaspi в формате 87001234567"));
bot.on("message:text", async (ctx) => {
  const phone = ctx.message.text.trim();
  if (!/^8\d{10}$/.test(phone)) return ctx.reply("Нужен формат 87001234567 (11 цифр)");
  const orderId = `tg-${ctx.chat.id}-${Date.now()}`;
  const r = await fetch("https://api.apipay.kz/api/v1/invoices", {
    method: "POST",
    headers: { "X-API-Key": process.env.APIPAY_API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      phone_number: phone, amount: 5000, description: "Оплата заказа в боте",
      external_order_id: orderId, external_order_id_idempotency: orderId,
    }),
  });
  if (r.status !== 201) return ctx.reply("Не удалось выставить счёт, попробуйте позже");
  await ctx.reply("Счёт отправлен — откройте приложение Kaspi и нажмите «Оплатить».");
});

const app = express();
app.post("/webhooks/apipay", express.raw({ type: "application/json" }), async (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.APIPAY_WEBHOOK_SECRET)
    .update(req.body).digest("hex"); // req.body — Buffer с сырым телом
  const got = Buffer.from(req.get("X-Webhook-Signature") || "");
  const exp = Buffer.from(expected);
  if (got.length !== exp.length || !crypto.timingSafeEqual(exp, got)) return res.status(401).end();
  res.status(200).end(); // сначала ответ, потом обработка

  const { event, invoice } = JSON.parse(req.body);
  const key = `${invoice?.id}:${invoice?.status}`;
  if (event === "invoice.status_changed" && invoice.status === "paid"
      && !processed.has(key) && invoice.external_order_id?.startsWith("tg-")) {
    processed.add(key);
    await bot.api.sendMessage(Number(invoice.external_order_id.split("-")[1]),
      `✅ Оплата получена: ${invoice.amount} ₸`);
  }
});
app.listen(8080);
bot.start();

Как протестировать без реальных денег

  1. Новый аккаунт стартует в песочнице: счета создаются в ApiPay, но в Kaspi не уходят и push не приходит — это нормально.
  2. Создайте счёт ботом → в кабинете apipay.kz (раздел «Счета») отметьте его оплаченным → на ваш endpoint придёт настоящий вебхук paid с is_sandbox: true — бот напишет в чат. Так проверяется весь контур, включая подпись.
  3. Локально вебхук принимайте через туннель: ngrok http 8080, полученный https-адрес — в кабинет. Пошагово: «Локальное тестирование вебхуков».
  4. Перед боем: подключите кассира (Настройки → «Авторизация Kaspi», ~1 минута — активируются 3 дня бесплатного рабочего режима) и переключите «Рабочий режим». Песочница остаётся бесплатной всегда — возвращайтесь в неё для экспериментов.

Типичные ошибки

  • 401 на создании счёта — нет/неверный X-API-Key. Ключ показывается один раз при создании; потерян — перегенерируйте («API-ключ и вебхук-секрет»).
  • «Всё работает, но push не приходит» — включена песочница. Переключите «Рабочий режим» в Настройках.
  • Вебхук не приходит — URL не публичный (localhost), ваш сервер отвечает не-2xx или дольше 5 секунд, либо после серии ошибок сработал предохранитель. Диагностика — «Настройка вебхуков» и Webhook-логи в кабинете.
  • Подпись «не сходится» — HMAC посчитан по распарсенному JSON. Только по сырому телу (в примерах: await request.read() / express.raw).
  • Счёт в error с client_not_found — номер не зарегистрирован в Kaspi. Попросите другой номер; заранее проверить можно через POST /clients/check.
  • Два счёта у одного покупателя — бот повторил запрос без external_order_id_idempotency. С ним повтор вернёт 409, дубль не создастся.
  • Сообщение «не оплачен», потом пришёл paid — легитимная гонка (cancelled → paid, expired → paid): засчитайте оплату, это не баг.
  • Накрутка неоплаченных счетов («нажму /start 50 раз») — ограничьте в боте число живых счетов на пользователя (например, 3); на стороне ApiPay тоже есть предохранитель: при лавине неоплаченных счетов на один номер API ответит 429.

Вопросы и ответы

Нужен ли собственный сервер?

Да, минимальный: вебхуку ApiPay нужен публичный HTTPS-endpoint. Подойдёт любой VPS, PaaS или туннель на время разработки. Без кода вообще — свяжите ApiPay с ботом через n8n: «Интеграция с n8n».

Можно ли не ждать вебхук, а опрашивать статус?

Технически да (GET /invoices/{id}), но это хуже: задержки и лимит 200 запросов/мин. Вебхук приходит сам за 10–20 секунд после оплаты.

Как принимать разные суммы?

amount в запросе — любое значение вашей корзины. Если организация работает с каталогом (Kaspi ОФД), используйте cart_items: «Счета с корзиной».

Что если покупатель не оплатил?

Через 24 часа счёт перейдёт в expired, придёт вебхук — бот может предложить выставить новый (наш код так и делает).

Подходит ли рецепт для Telegram Mini App?

Да: серверная часть та же (счёт + вебхук). Из Mini App вы просто дёргаете свой бэкенд, а не ApiPay напрямую — ключ в клиентском коде светить нельзя.

Где посмотреть, как это выглядит для покупателя?

Демо-бот: t.me/apipaydemo_bot — пройдите оплату глазами клиента.

Интеграция с ИИ

Не настраивайте вручную — поручите ИИ

Скопируйте готовый промпт и вставьте своему ИИ-ассистенту — он подключит приём платежей примерно за 15 минут. Настраивает ИИ, а не вы.

Для разработчика. Полная спецификация API — apipay.kz/docs. Эта статья в Markdown — /guides/kaspi-oplata-v-telegram-bote.md. Справочник фактов для ИИ-агента — apipay.kz/llms.txt.