Как настроить
- •В дашборде /dashboard/webhooks нажмите Создать webhook
- •Введите URL endpoint (HTTPS обязателен)
- •Выберите события для подписки
- •Скопируйте
secret— он показывается один раз
Или через API:
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"
}'В ответе:
{
"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.expired | QR (5 мин) или счёт (24 ч) истёк без оплаты |
payment.refunded | Возврат успешно проведён Kaspi |
webhook.test | Ручная тестовая отправка из дашборда |
Структура запроса
Headers
| Header | Значение |
|---|---|
X-Webhook-ID | wh_evt_abc123 — уникальный ID события |
X-Webhook-Timestamp | Unix timestamp в секундах |
X-Webhook-Signature | sha256= — HMAC подпись |
X-Webhook-Event | Тип события, например payment.completed |
User-Agent | PayBot-Webhooks/1.0 |
Content-Type | application/json |
Body
{
"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
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 "", 200Node.js
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
$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 (для отладки вручную)
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 сразу.
@app.post("/paybot")
def webhook():
verify_signature(request)
queue.enqueue(process_event, request.json)
return "", 200Retry 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:
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)Ротация секрета
При утечке или периодической ротации:
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для дебага
Что дальше
- •Журнал событий — все типы событий и их payload
- •Идемпотентность — стороны PayBot и вашей
- •Тестовый режим — как пуш-тестировать webhook без реальной оплаты