Архитектура: кто кому что шлёт
Покупатель ──/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();
Как протестировать без реальных денег
- Новый аккаунт стартует в песочнице: счета создаются в ApiPay, но в Kaspi не уходят и push не приходит — это нормально.
- Создайте счёт ботом → в кабинете apipay.kz (раздел «Счета») отметьте его оплаченным → на ваш endpoint придёт настоящий вебхук
paidсis_sandbox: true— бот напишет в чат. Так проверяется весь контур, включая подпись. - Локально вебхук принимайте через туннель:
ngrok http 8080, полученный https-адрес — в кабинет. Пошагово: «Локальное тестирование вебхуков». - Перед боем: подключите кассира (Настройки → «Авторизация 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 — пройдите оплату глазами клиента.