ГлавнаяБлог › Вебхуки или поллинг

Вебхуки или поллинг: как правильно отслеживать оплату Kaspi Pay

Сравнение двух подходов к доставке статуса платежа Kaspi Pay: что выбрать для Telegram-бота с одним пользователем, что — для SaaS с тысячами счетов в день, и почему вебхук не отменяет идемпотентности на стороне приёмки.

14 мая 2026 · 14 мин чтения · Архитектура

Kaspi Pay API Webhooks Polling Идемпотентность

Задача

Вы создали удалённый счёт в Kaspi через POST /v1/invoice/create и получили paymentId. Клиенту прилетело push-уведомление в Kaspi.kz. Дальше может произойти одно из пяти событий:

Ваш сервис должен гарантированно узнать о финальном статусе: выдать товар, поставить задачу в CRM, продлить подписку, выслать чек. Есть два способа это сделать — поллинг (вы периодически спрашиваете) и вебхуки (сервис вам сам говорит).

Поллинг: проще, но дороже

Поллинг — это цикл, который раз в N секунд дергает GET /v1/invoice/{paymentId} и сравнивает статус. Подход тривиальный, и для маленьких ботов его хватает с головой.

import time, requests

API = "https://pay.proverkacheka.kz/api"
H = {"X-API-Key": API_KEY}

def wait_for_payment(payment_id, timeout=900, interval=5):
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = requests.get(f"{API}/v1/invoice/{payment_id}", headers=H, timeout=10)
        r.raise_for_status()
        st = r.json()["status"]
        if st == "Processed":
            return True
        if st in {"RemotePaymentRejected", "RemotePaymentCanceled", "RemotePaymentExpired"}:
            return False
        time.sleep(interval)
    return False

Где поллинг ломается

  1. Расход. 5 секунд × 15 минут = 180 запросов на один счёт. При 100 одновременно ждущих счетах вы делаете 18 000 запросов в 15 минут. Сервис их выдержит, но если вы поллите со своего бекенда — это сетевые ресурсы и event-loop time, которые могли бы идти на пользователя.
  2. Состояние процесса. Если поллит фоновая задача, и приложение перезагрузилось (деплой, OOM, рестарт пода) — задача потеряна. Нужно сохранять «ожидающие» счета в БД и восстанавливать цикл при старте.
  3. Задержка. Минимум interval, в среднем половина. При interval=5 пользователь видит «Оплата принята» через ~2.5 сек после нажатия кнопки в Kaspi. Не катастрофа, но и не WOW.
  4. Идемпотентность. Если задача упала между «увидел Processed» и «выдал товар», после рестарта она поллит снова, снова видит Processed и снова выдаёт товар. Защита нужна.
Когда поллинг — нормальный выбор. Бот, где платежи — редкое событие (десятки в день), процесс долгоживущий, а 2–5 секунд задержки никого не волнуют. Для одной интеграции с одним продавцом поллинг честнее: меньше движущихся частей.

Вебхуки: дешевле, но сложнее

Вебхук — это POST на ваш URL в момент, когда статус меняется. Подписка живёт постоянно: вы зарегистрировали её один раз при старте, дальше получаете события для всех своих счетов.

# Подписка (один раз при инициализации)
curl -X POST https://pay.proverkacheka.kz/api/v1/webhooks \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-app.example.com/kaspi/webhook",
    "events": ["payment.success", "payment.failed", "payment.expired", "payment.refunded"],
    "secret": "long-random-shared-secret-min-8-chars",
    "label": "my-app"
  }'

Дальше PayProverkaBot мониторит ваши счета внутри сервиса и постит JSON-пейлоад на ваш url с заголовком X-Webhook-Signature: sha256=<hex>. Вы проверяете подпись (см. HMAC-SHA256 без багов), обновляете состояние и возвращаете 200.

Минимальный приёмник на Flask

from flask import Flask, request, abort
import hmac, hashlib, os

SECRET = os.environ["KASPI_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

@app.post("/kaspi/webhook")
def webhook():
    body = request.get_data()  # ВАЖНО: сырое тело, не request.json
    sig = request.headers.get("X-Webhook-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)

    event = request.get_json()
    handle_event(event)            # должен быть идемпотентным — см. ниже
    return "", 200

Ретраи: на что вы подписываетесь

Принимая вебхук, вы соглашаетесь на at-least-once доставку. PayProverkaBot повторит POST с экспоненциальным backoff, если ответ не 2xx или таймаут (до суток). Это значит:

Решение — идемпотентность по ключу. Простейшая схема: ваш обработчик считает успешно обработанным конкретный (paymentId, event) и сохраняет это в БД до того, как сделать что-то наружу (выдать товар, начислить кредиты, поставить задачу). Повторный приход того же ключа → ранний возврат 200.

def handle_event(event):
    key = (event["paymentId"], event["event"])
    with db.transaction():
        if db.exists("INSERT IGNORE INTO processed_events(payment_id, event) VALUES (?, ?)", key):
            # Ключ уже был обработан — ничего не делаем
            return
        # Внутри той же транзакции делаем бизнес-операцию
        if event["event"] == "payment.success":
            grant_access(event["paymentId"], event["amount"])
Антипаттерн. «Я обработаю event, потом запишу в processed_events». Между этими двумя действиями процесс может упасть — следующий ретрай повторит бизнес-операцию (двойное начисление кредитов, двойная отгрузка). Запись в processed_events должна быть в одной транзакции с эффектом или произойти раньше.

Когда что выбирать

СценарийЛучшеПочему
Один Telegram-бот, до 50 счетов в день Поллинг Простота важнее экономии. Нет публичного HTTPS-эндпоинта на бекенде.
SaaS с биллингом, 1000+ счетов/день Вебхуки Экономия запросов, мгновенный апдейт пользовательского кабинета.
CRM-интеграция (Bitrix24, Kommo, amoCRM) Вебхуки В CRM уже есть слот для входящего вебхука. Гайд по интеграциям.
Скрипт «одноразово выставить и подождать» Поллинг Подписка ради одного счёта — оверкилл.
Mobile-only приложение без бекенда Поллинг с фронта Принимать вебхук некуда; кладёте поллинг прямо в клиент.
Сервис с длинными деплоями / фриз-окнами Вебхуки Вебхук переживёт ваш рестарт благодаря ретраям; поллинг — нет.

Гибрид: поллинг как страховка вебхука

На больших нагрузках встречается схема «вебхук + поллинг как fallback». Логика такая: вы получаете вебхук в 99% случаев и реагируете мгновенно. Параллельно раз в 5–10 минут фоновая задача проходит по «зависшим» счетам (старше N минут, без финального статуса в локальной БД) и поллит их вручную.

Это защищает от трёх редких случаев: ваш URL был временно недоступен дольше, чем длится ретрай; событие было потеряно посредником; вы выкатывали изменения на схему БД и accidentaly уронили обработчик. Цена — пара десятков дополнительных GET в минуту.

Возвраты — отдельная история

Возврат — это не статус счёта, а отдельное действие со своим следом. Когда вы зовёте POST /v1/invoice/{id}/refund, сервис создаёт запись о возврате (полном или частичном) и шлёт payment.refunded с полями refundedAmount, isFull, remainingRefundable.

Несколько частичных возвратов по одному paymentId — нормально. Вебхук прилетает на каждый. Ваш обработчик должен суммировать, а не перезатирать. Это ещё одна причина не упрощать ключ идемпотентности до одного paymentId: тогда второй возврат будет проглочен как «уже был». Правильный ключ — (paymentId, event, refundId) для refund-событий.

Чек-лист перед запуском в прод