Webhook-и — это нервная система платёжной интеграции. Если они настроены плохо, вы либо узнаёте об оплате с опозданием на минуты, либо принимаете фейковые «платежи» от посторонних. Эта статья — как сделать webhook'и так, чтобы спать спокойно.
Что такое webhook и почему он лучше опроса
Когда клиент платит за заказ, ваш сервер должен узнать об этом. Есть два пути:
- Опрос (polling). Каждые N секунд дёргаете
GET /v1/qr/status/{id}. Просто, но медленно и расходует rate-limit. Подходит для скриптов и pet-проектов. - Webhook. Pay Bot сам отправляет POST на ваш URL в момент события. Узнаёте об оплате за 3–5 секунд. Подходит для продакшна.
Webhook'и масштабируются. Polling — нет: при 1 000 одновременных платежей вам пришлось бы делать тысячи запросов в секунду.
Какие события приходят
Pay Bot отправляет webhook'и для четырёх событий:
| Событие | Когда срабатывает |
|---------|-------------------|
| payment.completed | Клиент успешно оплатил |
| payment.cancelled | Клиент отменил оплату в Kaspi |
| payment.expired | Истёк срок действия QR |
| payment.refunded | Возврат прошёл через Kaspi |
Подписаться можно на любое подмножество — настраивается в личном кабинете.
Настройка эндпоинта
В разделе Webhooks в кабинете:
- Указываете URL — например,
https://yourshop.kz/api/kaspi-webhook. - Выбираете события.
- Получаете уникальный
secret— длинная случайная строка. Запишите её сразу, повторно она не показывается (по дизайну).
URL должен быть на HTTPS. HTTP принимается только в Sandbox.
Структура payload
Каждый webhook — POST с JSON-телом:
{
"id": "evt_01HN3X8Q...",
"event": "payment.completed",
"created_at": "2026-03-25T14:30:00Z",
"data": {
"operation_id": "op_8f3a2c...",
"amount": 5000,
"currency": "KZT",
"status": "paid",
"paid_at": "2026-03-25T14:29:57Z",
"sender_name": "Айбек К.",
"metadata": { "order_id": "12345" }
}
}
id — уникальный идентификатор события. Сохраняйте его, чтобы не обработать одно и то же событие дважды (см. ниже про идемпотентность).
Верификация HMAC-подписи
Самое важное. Любой может прислать POST на ваш URL — поэтому Pay Bot подписывает каждое сообщение HMAC-SHA256.
В заголовках придёт:
X-Kaspi-Signature: sha256=8a9b2c4d...
X-Kaspi-Event: payment.completed
X-Kaspi-Timestamp: 1714043400
Подпись считается от сырого тела запроса (важно: НЕ от распарсенного JSON — порядок ключей может измениться).
Python (Flask)
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"your_webhook_secret_here"
@app.post("/api/kaspi-webhook")
def handle():
raw = request.get_data()
sig = request.headers.get("X-Kaspi-Signature", "")
ts = int(request.headers.get("X-Kaspi-Timestamp", "0"))
# 1. Reject стары запросы (protection from replay)
if abs(time.time() - ts) > 300:
abort(401)
# 2. Verify HMAC
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(f"sha256={expected}", sig):
abort(401)
# 3. Обработка события
event = request.json
process_event(event)
return "", 200
Node.js (Express)
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.KASPI_WEBHOOK_SECRET;
// КРИТИЧНО: используем raw body для проверки подписи
app.post(
"/api/kaspi-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-Kaspi-Signature");
const ts = parseInt(req.header("X-Kaspi-Timestamp"), 10);
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).end();
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`),
Buffer.from(sig),
)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
processEvent(event);
res.status(200).end();
},
);
Три важных детали:
hmac.compare_digest/timingSafeEqual. Обычное===уязвимо к timing-атакам.- Raw body, не парсенный JSON. Иначе подпись не сойдётся.
- Проверка timestamp. Защита от replay-атак: если злоумышленник перехватил валидный webhook, он не сможет переиграть его через час.
Идемпотентность: не обработать одно событие дважды
Pay Bot гарантирует at-least-once delivery: каждое событие придёт минимум один раз, но иногда может прийти и дважды (например, если первая попытка вернула 200, но ответ потерялся в сети).
Решение — хранить event.id в БД и пропускать дубликаты:
def process_event(event):
event_id = event["id"]
# Уже обрабатывали? Тогда просто отвечаем OK.
if db.processed_events.find_one({"_id": event_id}):
return
# Атомарно: сохранить event_id + бизнес-логика
with db.transaction():
db.processed_events.insert({"_id": event_id, "received_at": now()})
order_id = event["data"]["metadata"]["order_id"]
db.orders.update({"id": order_id}, {"$set": {"paid": True}})
Pay Bot со своей стороны делает то же самое для исходящих запросов — каждое событие имеет фиксированный event_id, и retry не создаёт нового.
Retry-политика
Если ваш сервер не ответил 200 OK (или ответил, но через 30+ секунд), Pay Bot повторит запрос:
| Попытка | Через | |---------|-------| | 1 | 30 секунд | | 2 | 5 минут | | 3 | 30 минут | | 4 | 3 часа | | 5 | 10 часов |
После 5 неудач webhook помечается «failing», и придёт уведомление администратору. Можно вручную повторить из кабинета.
Что считать «успехом»: любой статус 2xx, ответ за 30 секунд. 4xx — Pay Bot считает «вы получили, но отказали», и не повторяет. 5xx — повторит.
Best practices
Отвечайте быстро, обрабатывайте асинхронно
Не пишите бизнес-логику в обработчике. Положите событие в очередь и сразу верните 200:
@app.post("/api/kaspi-webhook")
def handle():
verify_signature(...)
queue.enqueue(process_event, request.json) # Redis / Celery / SQS
return "", 200
Если ваш process_event упадёт — Pay Bot не сделает retry, потому что ответ-то был 200. Поэтому внутри очереди должна быть своя retry-логика.
Логируйте всё
event_id, event_type, received_at, processed_at, status, error
Это спасёт жизнь, когда клиент скажет «я заплатил, но статус не обновился».
Используйте metadata при создании платежа
При POST /v1/qr/create можно передать metadata: { order_id, user_id, ... }. Это поле возвращается в webhook'е — удобно для связывания платежа с вашей сущностью.
Тестируйте через Sandbox
В Sandbox-режиме можно генерировать тестовые платежи и webhook'и без денег. В кабинете есть кнопка «Test webhook» — она шлёт реальный POST с фиктивным платежом.
Не делайте webhook URL гадаемым
Плохо: /webhook. Хорошо: /api/v1/kaspi/webhook-3fa7b9.... Даже если ваш secret утечёт, у атакующего ещё должен быть URL.
Что делать, если webhook не приходит
Чек-лист, по которому стоит пройти:
- Открыт ли порт 443 наружу? Заблокировал ли firewall?
- Возвращает ли эндпоинт 200 за 30 секунд?
- Не отвергает ли nginx/Cloudflare запросы с пустым
User-Agentили нестандартным заголовком? - Если используете Cloudflare — добавлен ли путь в обход WAF (или хотя бы whitelist Pay Bot IP).
- В кабинете → Webhooks → выбрать конкретную доставку → посмотреть «last_error». Часто там и есть ответ.
Итого
Хорошо настроенный webhook-флоу выглядит так:
- Подпись HMAC проверяется на каждый запрос.
- Timestamp проверяется (защита от replay).
event.idдедуплицируется.- Ответ 200 возвращается за миллисекунды.
- Реальная обработка — в очереди.
- Всё логируется.
Это минимум для боевого окружения. Если интегрируете Pay Bot впервые — начните с Sandbox, поэкспериментируйте с retry, ловите свои ошибки в безопасной среде. Затем переходите на live.
Нужна помощь — пишите в @paybotkz_bot или оставляйте заявку. Покажем рабочие примеры под ваш стек.
Готовы попробовать Pay Bot?
Зарегистрируйтесь в кабинете — Sandbox доступен сразу. Или оставьте заявку, и менеджер настроит подключение Kaspi за вас.