> Источник: https://apipay.kz/guides/kaspi-oplata-v-telegram-bote · Обновлено: 2026-07-06 · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

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

**TL;DR.** Схема из четырёх звеньев: **бот → ApiPay API → покупатель платит в Kaspi → вебхук → уведомление в чат**. Бот спрашивает номер телефона, делает `POST /invoices` (счёт живёт 24 часа), покупатель получает push в приложении Kaspi и платит в одно касание, ApiPay присылает вебхук `paid` на ваш сервер — бот пишет в чат «Оплата получена». Деньги идут напрямую на ваш Kaspi-счёт. Ниже — полный рабочий файл на Python (aiogram 3.x, ~80 строк) и короткая версия на Node (grammY). Эквайринг, карты и договор с банком не нужны — только приложение Kaspi Pay с ролью «Кассир» и подписка ApiPay.

## Коротко

| Вопрос | Ответ |
|---|---|
| Что нужно | API-ключ ApiPay + вебхук-секрет (кабинет apipay.kz) + публичный HTTPS-URL для вебхука |
| Создание счёта | `POST https://api.apipay.kz/api/v1/invoices`, заголовок `X-API-Key` |
| Ключ маршрутизации «счёт → чат» | `external_order_id` вида `tg-{chat_id}-{время}` — он вернётся в вебхуке |
| Подтверждение оплаты | Вебхук `invoice.status_changed` со `status: "paid"`, обычно за 10–20 секунд |
| Защита от дублей счёта | `external_order_id_idempotency` → повтор даёт `409` |
| Тест без денег | Песочница: счета в Kaspi не уходят, оплату отмечаете в кабинете — вебхук приходит с `is_sandbox: true` |
| Как это выглядит для клиента | Демо-бот: t.me/apipaydemo_bot |

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

```
Покупатель ──/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.

```python
"""
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

```js
// 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-адрес — в кабинет. Пошагово: «[Локальное тестирование вебхуков](/local-testing)».
4. Перед боем: подключите кассира (Настройки → «Авторизация Kaspi», ~1 минута — активируются 3 дня бесплатного рабочего режима) и переключите «Рабочий режим». Песочница остаётся бесплатной всегда — возвращайтесь в неё для экспериментов.

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

- **`401` на создании счёта** — нет/неверный `X-API-Key`. Ключ показывается один раз при создании; потерян — перегенерируйте («[API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret)»).
- **«Всё работает, но push не приходит»** — включена песочница. Переключите «Рабочий режим» в Настройках.
- **Вебхук не приходит** — URL не публичный (localhost), ваш сервер отвечает не-2xx или дольше 5 секунд, либо после серии ошибок сработал предохранитель. Диагностика — «[Настройка вебхуков](/guides/nastroyka-webhookov-apipay)» и 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](/n8n-integration)».

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

**Как принимать разные суммы?**
`amount` в запросе — любое значение вашей корзины. Если организация работает с каталогом (Kaspi ОФД), используйте `cart_items`: «[Счета с корзиной](/guides/scheta-s-korzinoy-cart-items-ofd)».

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

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

**Где посмотреть, как это выглядит для покупателя?**
Демо-бот: **t.me/apipaydemo_bot** — пройдите оплату глазами клиента.

Смотрите также: пиллар «[Как принимать оплату Kaspi через API](/kaspi-api)» · «Kaspi-оплата на сайте» · «[Интеграция с n8n](/n8n-integration)».

---

ApiPay — независимый сервис и не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.
База знаний: https://apipay.kz/guides
