Блог
Kaspi Pay webhooks уведомления о платежах HMAC подпись API интеграция

Kaspi Pay Webhooks: HMAC, retry-политика и продакшн-готовая интеграция

Полное руководство по webhook-уведомлениям Kaspi Pay API: настройка, верификация HMAC-SHA256 подписи, retry-политика, идемпотентность, безопасность и примеры на Python и Node.js.

Pay Bot 25 марта 2026 г. 4 мин чтения

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 в кабинете:

  1. Указываете URL — например, https://yourshop.kz/api/kaspi-webhook.
  2. Выбираете события.
  3. Получаете уникальный 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();
  },
);

Три важных детали:

  1. hmac.compare_digest / timingSafeEqual. Обычное === уязвимо к timing-атакам.
  2. Raw body, не парсенный JSON. Иначе подпись не сойдётся.
  3. Проверка 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 не приходит

Чек-лист, по которому стоит пройти:

  1. Открыт ли порт 443 наружу? Заблокировал ли firewall?
  2. Возвращает ли эндпоинт 200 за 30 секунд?
  3. Не отвергает ли nginx/Cloudflare запросы с пустым User-Agent или нестандартным заголовком?
  4. Если используете Cloudflare — добавлен ли путь в обход WAF (или хотя бы whitelist Pay Bot IP).
  5. В кабинете → Webhooks → выбрать конкретную доставку → посмотреть «last_error». Часто там и есть ответ.

Итого

Хорошо настроенный webhook-флоу выглядит так:

  1. Подпись HMAC проверяется на каждый запрос.
  2. Timestamp проверяется (защита от replay).
  3. event.id дедуплицируется.
  4. Ответ 200 возвращается за миллисекунды.
  5. Реальная обработка — в очереди.
  6. Всё логируется.

Это минимум для боевого окружения. Если интегрируете Pay Bot впервые — начните с Sandbox, поэкспериментируйте с retry, ловите свои ошибки в безопасной среде. Затем переходите на live.

Нужна помощь — пишите в @paybotkz_bot или оставляйте заявку. Покажем рабочие примеры под ваш стек.

Готовы попробовать Pay Bot?

Зарегистрируйтесь в кабинете — Sandbox доступен сразу. Или оставьте заявку, и менеджер настроит подключение Kaspi за вас.