Webhooks

Webhooks PayBot — подписанные HMAC-SHA256 уведомления о платежах Kaspi. Retry с exponential backoff, dead-letter и примеры на Python и Node.js.

Webhook — это HTTPS POST на ваш URL с подписью HMAC-SHA256. PayBot шлёт его в момент изменения статуса платежа: оплачено, отменено, истекло, возвращено.

Как настроить

  • В дашборде /dashboard/webhooks нажмите Создать webhook
  • Введите URL endpoint (HTTPS обязателен)
  • Выберите события для подписки
  • Скопируйте secret — он показывается один раз

Или через API:

bash
Скачать
curl -X POST https://api.paybot.kz/me/webhooks \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yoursite.com/paybot",
    "events": ["payment.completed", "payment.refunded"],
    "description": "Production"
  }'

В ответе:

json
Скачать
{
  "id": 42,
  "url": "https://yoursite.com/paybot",
  "events": ["payment.completed", "payment.refunded"],
  "secret": "whsec_abc123def456...",
  "secret_revealed_at": "2026-05-11T12:00:00Z",
  "secret_version": 1,
  "enabled": true
}
Сохраните secret сразу — в plaintext он больше не вернётся. Если потерян — используйте ротацию.

События

СобытиеКогда срабатывает
payment.completedПлатёж успешно прошёл в Kaspi
payment.cancelledКлиент или Kaspi отменили платёж
payment.expiredQR (5 мин) или счёт (24 ч) истёк без оплаты
payment.refundedВозврат успешно проведён Kaspi
webhook.testРучная тестовая отправка из дашборда

Структура запроса

Headers

HeaderЗначение
X-Webhook-IDwh_evt_abc123 — уникальный ID события
X-Webhook-TimestampUnix timestamp в секундах
X-Webhook-Signaturesha256= — HMAC подпись
X-Webhook-EventТип события, например payment.completed
User-AgentPayBot-Webhooks/1.0
Content-Typeapplication/json

Body

json
Скачать
{
  "id": "wh_evt_abc123",
  "event": "payment.completed",
  "created_at": "2026-05-11T12:30:00Z",
  "mode": "live",
  "data": {
    "operation_id": 14837690870,
    "amount": 5000,
    "status": "Processed",
    "phone": "77001234567",
    "sender_name": "Иван И.",
    "metadata": {"order_id": "ord_1234"},
    "completed_at": "2026-05-11T12:29:55Z"
  }
}

Поля data зависят от типа события — см. Журнал событий.

Верификация подписи

Алгоритм для всех языков одинаков:

  • Получите timestamp из X-Webhook-Timestamp
  • Получите signature из X-Webhook-Signature (без префикса sha256=)
  • Соберите строку f"{timestamp}.{body}" (тело — как пришло, без перепарсинга)
  • Посчитайте HMAC-SHA256(secret, signed_payload), приведите к hex
  • Сравните через constant-time compare
  • Отбросьте, если |now - timestamp| > 300 секунд (защита от replay)

Python

python
Скачать
import hmac, hashlib, time
from flask import request, abort

SECRET = "whsec_abc123def456..."

@app.post("/paybot")
def webhook():
    ts = request.headers["X-Webhook-Timestamp"]
    sig = request.headers["X-Webhook-Signature"]
    body = request.get_data()

    if abs(int(time.time()) - int(ts)) > 300:
        abort(400, "timestamp too old")

    expected = "sha256=" + hmac.new(
        SECRET.encode(),
        f"{ts}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(401, "invalid signature")

    event = request.json
    handle_event(event)
    return "", 200

Node.js

javascript
Скачать
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.PAYBOT_WEBHOOK_SECRET;
const app = express();

app.post("/paybot",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts  = req.headers["x-webhook-timestamp"];
    const sig = req.headers["x-webhook-signature"];
    const body = req.body;  // Buffer, raw

    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
      return res.status(400).end("stale");
    }

    const expected = "sha256=" + crypto
      .createHmac("sha256", SECRET)
      .update(ts + ".")
      .update(body)
      .digest("hex");

    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
      return res.status(401).end("bad signature");
    }

    const event = JSON.parse(body.toString());
    handleEvent(event);
    res.status(200).end();
  });

PHP

php
Скачать
<?php
$SECRET = getenv("PAYBOT_WEBHOOK_SECRET");

$ts   = $_SERVER["HTTP_X_WEBHOOK_TIMESTAMP"] ?? "";
$sig  = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";
$body = file_get_contents("php://input");

if (abs(time() - intval($ts)) > 300) {
    http_response_code(400);
    exit("stale");
}

$expected = "sha256=" . hash_hmac("sha256", "$ts.$body", $SECRET);

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit("bad signature");
}

$event = json_decode($body, true);
handleEvent($event);
http_response_code(200);

curl (для отладки вручную)

bash
Скачать
TS=1731331800
BODY='{"event":"payment.completed",...}'
SIG=$(echo -n "$TS.$BODY" | openssl dgst -sha256 -hmac "whsec_..." -hex | awk '{print $2}')
echo "X-Webhook-Signature: sha256=$SIG"

Ответ на webhook

Возвращайте 2xx только если событие полностью обработано. Любой 4xx/5xx или таймаут (>10 сек) считается ошибкой — PayBot ретраит.

Если обработка тяжёлая — поставьте задачу в очередь и ответьте 200 сразу.

python
Скачать
@app.post("/paybot")
def webhook():
    verify_signature(request)
    queue.enqueue(process_event, request.json)
    return "", 200

Retry policy

ПопыткаЗадержка
1мгновенно
2через 30 сек
3через 5 мин
4через 30 мин
5через 2 часа

После 5 неудачных попыток delivery помечается failed. Webhook переходит в failing — ему создаётся событие webhook.delivery_failed в журнале событий. Если 10 deliveries подряд failed, webhook автоматически отключается.

Идемпотентность на вашей стороне

PayBot гарантирует at-least-once доставку. Один и тот же X-Webhook-ID может прийти дважды (например, при таймауте сети). Дедуплицируйте по X-Webhook-ID:

python
Скачать
def handle_event(event):
    if EventLog.objects.filter(event_id=event["id"]).exists():
        return  # already processed
    EventLog.objects.create(event_id=event["id"])
    process(event)

Ротация секрета

При утечке или периодической ротации:

bash
Скачать
curl -X POST https://api.paybot.kz/me/webhooks/42/rotate-secret \
  -H "Authorization: Bearer YOUR_JWT"

Новый secret показывается один раз, secret_version увеличивается на 1. Старый секрет перестаёт работать немедленно — выкатите код с новым секретом до ротации.

Управление

EndpointОписание
GET /me/webhooksСписок ваших webhooks
PATCH /me/webhooks/{id}Изменить URL, events, description
POST /me/webhooks/{id}/rotate-secretНовый secret
POST /me/webhooks/{id}/disableВременно выключить без удаления
POST /me/webhooks/{id}/enableВключить обратно
DELETE /me/webhooks/{id}Удалить навсегда
GET /me/webhooks/{id}/deliveries?cursor=История доставок
POST /me/webhooks/{id}/deliveries/{did}/retryПовторить вручную
POST /me/webhooks/{id}/testОтправить тестовое событие

Best practices

  • Всегда проверяйте X-Webhook-Timestamp, чтобы отбросить старые запросы
  • Используйте hmac.compare_digest (или эквивалент) — обычный == уязвим к timing-атакам
  • Не парсите JSON до проверки подписи — подмена тела не должна повлиять на verify
  • Дедуплицируйте по X-Webhook-ID
  • Отвечайте за < 10 секунд, тяжёлую работу — в очередь
  • Логируйте X-Webhook-ID и X-Webhook-Timestamp для дебага

Что дальше