Kaspi Pay в боте без скриншотов: QR, подписанный вебхук и поллер-страховка
Продажа через Telegram в Казахстане часто устроена вручную: бот или менеджер пишет «переведите 4 990 ₸ на Kaspi», покупатель присылает скриншот, человек ищет платёж в приложении. Ночью заказы ждут до утра, поддельный чек проходит, если проверяющий устал.
Ниже разобрана схема, в которой бот сам выставляет QR Kaspi Pay, за несколько секунд узнаёт об оплате и выдаёт товар. Код занимает около 200 строк на Python (aiogram 3, aiohttp, httpx, qrcode). Пример написан под REST API Aitole — посредника, который выставляет счета через Kaspi-аккаунт продавца, — но цепочка «QR → вебхук → сверка» от провайдера не зависит: при смене посредника меняются URL и имена полей.
Почему Telegram Bot Payments не подходят
Встроенные платежи Telegram работают через эквайринг: договор, проверка, процент с оборота. У большинства небольших продавцов эквайринга нет, зато есть Kaspi Pay для бизнеса, через который покупатели и так привыкли платить. Автоматизировать приходится именно его.
Посредник подключается к Kaspi Pay через штатную роль «Кассир»: она умеет выставлять счета и не умеет выводить деньги. Деньги идут сразу на счёт продавца. Для бота это HTTP API с тремя действиями: создать счёт, узнать статус, получить событие.
Для локальной разработки нужен туннель (ngrok, cloudflared): localhost и внутренние адреса как адрес вебхука не принимаются. Тестовый ключ sk_test_… работает только с песочницей; запрос с ним к боевому API получает 403 key_mode_mismatch. Требуйте такое разделение от любого платёжного API: случайно списать живые деньги из dev-окружения становится невозможно.
Подписка на вебхук создаётся в личном кабинете, по API-ключу её не настроить. Так утёкший ключ не позволит перенаправить ваши события на чужой адрес. Секрет whsec_… показывается один раз.
Выставление QR
async def create_qr(order: dict) -> dict:
order["attempt"] += 1
r = await http.post(
"/v2/qr",
headers={"Idempotency-Key": f"tgbot-{order['id']}-{order['attempt']}"},
json={"amount": order["amount"], "comment": order["title"],
"metadata": {"order_id": order["id"]}},
)
body = r.json()
if r.status_code >= 400:
err = body["error"]
raise AitoleError(f"{err['code']}: {err['message']}")
if body["status"] == "failed":
raise AitoleError(body.get("failure_message") or "Kaspi отказал")
order["op_id"] = body["id"]
by_op[body["id"]] = order["id"]
return body
Idempotency-Key. httpx падает по таймауту, пользователь дважды жмёт «Купить» — повтор с тем же ключом вернёт тот же QR, второго счёта не будет. Ключ уникален в пределах аккаунта и живёт 24 часа, поэтому у него есть префикс tgbot-: если сайт тоже нумерует заказы с единицы, ключи не столкнутся. Номер попытки в ключе нужен для перевыставления после истечения QR — со старым ключом вернулся бы старый, уже мёртвый счёт.
HTTP 200 при отказе. Если Kaspi отклонил операцию, ответ приходит с кодом 200, status: "failed" и причиной в failure_message. Одного raise_for_status() мало.
Создание счёта ничего не говорит об оплате. В ответе операция в статусе pending, ссылка qr_token и expires_at. Об оплате сообщит событие.
qr_token — обычная ссылка. Бот отправляет одним сообщением картинку QR (для Telegram Desktop) и inline-кнопку с этой ссылкой (с телефона она открывает Kaspi). QR живёт 5 минут, это стоит написать в подписи. Kaspi не сообщает, кто заплатил по QR, поэтому op_id хранится рядом с заказом и chat_id: по нему бот потом найдёт получателя товара.
Приём вебхука
Событие приходит с заголовками X-Webhook-Timestamp и X-Webhook-Signature: sha256=<hex>. Подпись — HMAC-SHA256 от строки "{timestamp}.{сырое тело}" секретом эндпоинта.
def signature_ok(raw: bytes, ts: str, got: str) -> bool:
mine = "sha256=" + hmac.new(WEBHOOK_SECRET, ts.encode() + b"." + raw,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(mine, got):
return False
try:
return abs(time.time() - int(ts)) <= 300
except ValueError:
return False
async def aitole_webhook(request: web.Request) -> web.Response:
raw = await request.read()
if not signature_ok(raw, request.headers.get("X-Webhook-Timestamp", ""),
request.headers.get("X-Webhook-Signature", "")):
return web.Response(status=400)
event = json.loads(raw)
if event["id"] not in seen_events:
seen_events.add(event["id"])
asyncio.create_task(handle_event(event))
return web.Response(status=200)
Типичные ошибки в этом месте:
- Подпись по распарсенному JSON.
json.dumps(json.loads(raw))меняет пробелы и порядок ключей, HMAC не сойдётся. Считать нужно по байтам изrequest.read(). - Сравнение через
==. Строковое сравнение прерывается на первом отличии, и по времени ответа подпись можно подбирать.hmac.compare_digestработает за постоянное время. - Нет окна свежести. Без проверки timestamp в пределах 300 секунд перехваченный запрос «оплачено» можно повторить через неделю.
- Тяжёлая работа внутри запроса. Таймаут отправителя — 10 секунд. Если за это время бот ходит в базу и шлёт три сообщения, успешная доставка засчитывается как неудачная и повторяется. Ответ 200 уходит сразу, обработка — в фоновую задачу, в продакшене — в очередь.
- Расчёт на порядок событий. Неудачная доставка повторяется через 1 мин, 5 мин, 30 мин, 2 ч, 6 ч и 24 ч, порядок не гарантирован: повтор
payment.createdможет прийти послеpayment.paid. Дедупликация идёт поidконверта, решение принимается поdata.status.
Обработка событий и тихая смерть сессии
handle_event сверяет data.id с текущим op_id заказа и отбрасывает события от чужих и старых попыток. На payment.paid со статусом paid вызывается on_paid, на payment.expired — on_expired, который присылает кнопку «Новый QR» (новая попытка, новый Idempotency-Key). on_paid первым делом проверяет, не выдан ли товар: оплату может принести и вебхук, и сверка.
Отдельного внимания требует connection.session_expired. У Kaspi одна активная сессия на номер. Если кто-то войдёт в приложение Kaspi Pay под номером кассира, отданным посреднику, сессия посредника слетит, и новые счета начнут отклоняться с 409 no_active_connection. Бот при этом продолжает отвечать на /start, но каждый новый счёт падает. Отсюда два правила: номер кассира выделенный и под ним никто не логинится, а событие сразу уходит админу в Telegram. Проверка «бот отвечает» такую поломку не ловит, мониторить надо факт приёма платежей.
Сверка по таймеру
Сервер перезагрузился, туннель упал, сертификат истёк — вебхук потерян, покупатель заплатил и ждёт. Раз в минуту фоновая задача проходит по заказам в pending старше 60 секунд и запрашивает GET /v2/operations/{op_id}; статусы paid и expired ведут в те же on_paid и on_expired. При большом потоке удобнее читать журнал событий GET /v2/events с курсором: он хранит всю историю и переживает любой сбой доставки. Вебхук даёт скорость, опрос или журнал — гарантию доставки. В продакшене нужны оба.
Проверка в песочнице
С тестовым ключом qr_token имеет вид mock_qr_…, оплатить его нельзя. Состояние операции двигается вручную:
curl -X POST https://api.aitole.kz/v2/sandbox/operations/op_…/pay -H "X-API-Key: sk_test_…"
curl -X POST https://api.aitole.kz/v2/sandbox/operations/op_…/expire -H "X-API-Key: sk_test_…"
curl -X POST https://api.aitole.kz/v2/sandbox/operations/op_…/reject -H "X-API-Key: sk_test_…"
Через пару секунд прилетает настоящий подписанный вебхук. Чек-лист:
- повторное нажатие «Купить» при искусственном таймауте не создаёт второй счёт;
- вебхук с испорченной подписью и со старым timestamp получает
400; - при выключенном вебхуке заказ закрывается сверкой через минуту;
expire→ «Новый QR» → оплата новой попытки проходит.
Postgres: условный UPDATE решает гонку
Словари в памяти после рестарта теряют открытые заказы и обработанные события, и один платёж выдаёт товар дважды или ни разу. Минимальная схема:
CREATE TABLE orders (
id text PRIMARY KEY,
chat_id bigint NOT NULL,
amount integer NOT NULL CHECK (amount > 0),
status text NOT NULL DEFAULT 'pending',
attempt integer NOT NULL DEFAULT 0,
op_id text UNIQUE,
paid_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE processed_events (
event_id text PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now()
);
Переход в paid делается одним условным апдейтом, и товар выдаётся, только если апдейт вернул строку:
UPDATE orders SET status = 'paid', paid_at = now()
WHERE op_id = $1 AND status = 'pending'
RETURNING chat_id;
Если вебхук и сверка пришли одновременно, строку обновит один из них, второй получит пустой результат. Дедупликация событий — INSERT ... ON CONFLICT (event_id) DO NOTHING в той же транзакции. Проверка if order["status"] == "paid" в памяти процесса такой гарантии не даёт.
Перед боевым запуском
- Боевой ключ со scope только
create_qrи потолком суммы: утёкший ключ не выставит счёт на миллион. - Лимит боевых ключей — 200 запросов в минуту, на
429ждатьRetry-After. - Суммы хранить в целых тенге, без float.
- Фискальный чек пробивает ваша онлайн-касса, платёжный API этим не занимается.