Главная › Блог › Вебхуки или поллинг
Вебхуки или поллинг: как правильно отслеживать оплату Kaspi Pay
Сравнение двух подходов к доставке статуса платежа Kaspi Pay: что выбрать для Telegram-бота с одним пользователем, что — для SaaS с тысячами счетов в день, и почему вебхук не отменяет идемпотентности на стороне приёмки.
Задача
Вы создали удалённый счёт в Kaspi через
POST /v1/invoice/create и получили
paymentId. Клиенту прилетело push-уведомление в Kaspi.kz.
Дальше может произойти одно из пяти событий:
- Клиент оплатил → статус становится
Processed. - Клиент нажал «Отклонить» →
RemotePaymentRejected. - Вы передумали и отменили счёт →
RemotePaymentCanceled. - Прошло ~15 минут без действия →
RemotePaymentExpired. - Клиент оплатил и потом вы сделали возврат →
Refunded/PartiallyRefunded.
Ваш сервис должен гарантированно узнать о финальном статусе: выдать товар, поставить задачу в 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
Где поллинг ломается
- Расход. 5 секунд × 15 минут = 180 запросов на один счёт. При 100 одновременно ждущих счетах вы делаете 18 000 запросов в 15 минут. Сервис их выдержит, но если вы поллите со своего бекенда — это сетевые ресурсы и event-loop time, которые могли бы идти на пользователя.
- Состояние процесса. Если поллит фоновая задача, и приложение перезагрузилось (деплой, OOM, рестарт пода) — задача потеряна. Нужно сохранять «ожидающие» счета в БД и восстанавливать цикл при старте.
-
Задержка. Минимум
interval, в среднем половина. Приinterval=5пользователь видит «Оплата принята» через ~2.5 сек после нажатия кнопки в Kaspi. Не катастрофа, но и не WOW. -
Идемпотентность. Если задача упала между «увидел
Processed» и «выдал товар», после рестарта она поллит снова, снова видитProcessedи снова выдаёт товар. Защита нужна.
Вебхуки: дешевле, но сложнее
Вебхук — это 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с одним и тем же событием может прийти 2–10 раз. - События могут прийти не в порядке (редко, но возможно).
- Если приёмник медленный (> 10 сек) — будет таймаут, дубль улетит позже.
Решение — идемпотентность по ключу. Простейшая схема: ваш
обработчик считает успешно обработанным конкретный
(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"])
Когда что выбирать
| Сценарий | Лучше | Почему |
|---|---|---|
| Один 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-событий.
Чек-лист перед запуском в прод
- HTTPS-эндпоинт под вебхук (без TLS не работает).
- Проверка подписи
X-Webhook-Signature— обязательно. См. HMAC-SHA256 без багов. - Идемпотентность по ключу
(paymentId, event)в одной транзакции с эффектом. - Возврат
200только после успешной обработки — иначе ретраи. - Логирование с
X-Webhook-Attempt— поможет понять, ретрай ли это. - Таймаут ответа ≤ 5 сек — тяжёлые операции в очередь.
- Подписка на нужные события (
payment.success,payment.failed,payment.expired,payment.refunded) — лишние не подписывайте, чтобы не получать шум. - (Опционально) Fallback-поллинг для зависших счетов раз в 5–10 минут.
Что почитать дальше
- Проверка подписи вебхуков Kaspi Pay: HMAC-SHA256 без багов — отдельный разбор подписи на трёх языках.
- Интеграция Kaspi Pay в Bitrix24, Kommo и amoCRM — конкретные коннекторы.
- Документация по вебхукам на главной — список событий и формат пейлоада.
- Если вы валидируете уже принятые чеки (а не выставляете счета) — это делает ProverkaCheka.kz, парный сервис с публичным API для верификации фискальных чеков Kaspi.