Коротко
Telegram-бот не обращается в Qut Pay напрямую — между ними стоит ваш сервер. Покупатель выбирает товар в боте → бот сообщает вашему серверу → сервер создаёт счёт → бот присылает картинку QR или ссылку на оплату (либо покупателю прилетает пуш в Kaspi) → после подтверждения к вам приходит вебхук → сервер говорит боту выдать товар. API-ключ живёт только на сервере, в коде бота его нет.
Как это работает
| Шаг | Кто | Что делает |
|---|---|---|
| 1 | Покупатель | Выбирает товар в боте и нажимает «Оплатить» |
| 2 | Бот | Шлёт запрос на ваш сервер (chat_id, товар, сумма) |
| 3 | Сервер | POST /api/v1/invoices, кладёт chat_id в metadata |
| 4 | Бот | Отправляет картинку qrImageUrl или ссылку payUrl |
| 5 | Покупатель | Подтверждает оплату в Kaspi |
| 6 | Qut Pay | Шлёт на сервер invoice.paid |
| 7 | Сервер | По metadata.chat_id сообщает боту, бот выдаёт товар |
Положить chat_id в metadata — ключевая деталь схемы: когда придёт вебхук, вы сразу знаете, кому отвечать, и не ищете покупателя по своей базе.
Какой метод API используется
Создание счёта — POST /api/v1/invoices. Два вида:
kind: "qr"— возвращает QR и ссылку. Покупатель видит картинку в боте или жмёт ссылку. Описание — до 100 символов.kind: "phone"— покупателю приходит пуш в приложение Kaspi, обязателенcustomer.phoneв формате7XXXXXXXXXX. Сумма — целые тенге, описание — 60 символов.
Если номер покупателя известен, в боте удобнее phone: ничего сканировать не нужно, Kaspi открывается сам. Номер можно получить кнопкой «Отправить контакт» прямо в Telegram.
Остальное: GET /api/v1/invoices/{id} — статус, POST /api/v1/invoices/{id}/cancel — отмена, POST /api/v1/invoices/{id}/refund — возврат.
Короткий пример
Python:
import requests
r = requests.post(
"https://api.qut.kz/api/v1/invoices",
headers={"X-API-Key": API_KEY, "Idempotency-Key": f"tg-{chat_id}-{cart_id}"},
json={
"amount": 5900,
"kind": "qr",
"description": "Курс: первый модуль",
"externalOrderId": str(cart_id),
"metadata": {"chat_id": chat_id, "bot": "kurs_bot"},
},
timeout=15,
)
inv = r.json()
bot.send_photo(chat_id, inv["qrImageUrl"], caption=f"Оплатить: {inv['payUrl']}")
Node.js:
const res = await fetch('https://api.qut.kz/api/v1/invoices', {
method: 'POST',
headers: {
'X-API-Key': process.env.QUTPAY_KEY,
'Idempotency-Key': `tg-${chatId}-${cartId}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 5900,
kind: 'phone',
description: 'Курс: первый модуль',
customer: { phone: '77010000000' },
metadata: { chat_id: chatId, bot: 'kurs_bot' },
}),
});
const inv = await res.json();
На стороне вебхука берёте сырое тело, считаете HMAC-SHA256(secret, timestamp + "." + rawBody), сравниваете с заголовком и по payload.metadata.chat_id сообщаете боту. Есть готовые SDK: Node.js, PHP, Python.
Как не накопить неоплаченные счета
В боте это реальная проблема: люди жмут «Оплатить» и уходят. Несколько сотен открытых счетов могут упереться в суточную защиту.
- Один покупатель — один открытый счёт. Перед созданием нового отмените прежний через
cancelили проверьте его статус. Храните у себя связкуchat_id → последний invoice id. - Ставьте
Idempotency-Key. Даже если кнопку нажали пять раз, счёт будет один. Включите в ключchat_idи номер корзины. - Блокируйте кнопку. После создания счёта уберите «Оплатить» или замените на «Ожидаем оплату».
- Убирайте истёкшие QR сами. Когда прошло время
expiresAt, отредактируйте сообщение и поставьте кнопку «Получить новый счёт». - Помните: суточное число — не бизнес-лимит, а защита от интеграции, ушедшей в цикл. Она даёт ошибку
tariff_daily_burst, а месячный лимит —tariff_limit_reached. Это разные вещи: Какой тариф выбрать.
Несколько брендов в одном боте
Если в боте продаётся несколько магазинов или направлений, есть два пути.
Одна организация, разная пометка. Все деньги приходят на один счёт в Kaspi. Бренд пишете в metadata ({"brand": "shop_a"}) и по нему разделяете отчётность. Самый простой вариант.
Разные ключи. Для каждого направления заводите отдельный API-ключ — тогда счета удобно фильтровать по источнику. Ключ можно привязать к конкретному кассиру: привязанный ключ видит только счета этого кассира, на чужие отвечает 404. Про кассиров: Можно ли подключить несколько кассиров.
Если деньги должны идти на разные счета — это уже разные организации, у каждой свой кассир и свой тариф: Несколько организаций в одном аккаунте.
На что обратить внимание
- Не путайте токен бота и API-ключ. Оба лежат на сервере, но API-ключ никогда не передаётся в Telegram.
- Поздняя оплата. Если деньги придут на истёкший счёт, событие
invoice.paidпридёт с признакомlate: true. Обработайте это в боте: либо выдайте товар, либо верните деньги. - Обработчик вебхука должен быть идемпотентным. Одно событие может прийти повторно; обрабатывайте пару
(invoice.id, status)один раз, иначе покупатель получит товар дважды. - У нас есть свой бот. Он не для продаж, а для контроля:
/invoice,/today,/last,/status,/cancel,/support. Подключите его рядом, чтобы видеть счета с телефона: руководство по Telegram-боту. - Прогоните весь цикл в песочнице: счёт →
simulate→ вебхук → бот выдал товар.
Вопросы и ответы
Можно положить API-ключ в код бота? Нет. Даже если бот и ключ на одном сервере, читайте ключ из переменной окружения и не коммитьте в репозиторий. Если ключ утёк — удалите его и создайте новый.
А если я не знаю номер покупателя? Делайте QR-счёт, для него номер не нужен. Пуш на телефон работает только при kind: "phone".
Если бот упал, деньги потеряются? Нет. Оплата проходит в Kaspi, деньги приходят на ваш счёт. Когда бот поднимется, вебхук придёт снова — мы повторяем доставку 11 раз, пока не получим 2xx.
Можно продавать в групповом чате? Бота можно добавить в группу, но ссылку на оплату лучше слать в личный чат: в ней видны сумма и описание.
Покупатель может отсканировать QR прямо из бота? На одном телефоне это неудобно. Поэтому в боте лучше давать ссылку payUrl или пуш kind: "phone", а картинку QR оставить запасным вариантом.