Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Трендовые github проекты в нашем телеграм канале. Подпишись →
17 октября 2026 г.

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 этим не занимается.