Webhook — это HTTP-уведомление, которое PayBot отправляет на ваш URL, когда с платежом что-то происходит: клиент оплатил QR или счёт, платёж отменён, истёк или возвращён. Зарегистрируйте endpoint через POST /me/webhooks, проверяйте подпись X-Webhook-Signature (HMAC-SHA256 над строкой "{timestamp}.{body}") и отвечайте 2xx — так вы получаете статус оплаты в реальном времени, не опрашивая API поллингом.
Вебхуки — это push-модель: PayBot сам стучится к вам при событии. Если вам нужна pull-модель (забирать события по запросу, например после простоя сервера), используйте журнал событий — тот же поток данных, но через GET /v2/events с курсором.
Все запросы к /me/webhooks* авторизуются JWT кабинета (Authorization: Bearer ), а не API-ключом. Управление вебхуками живёт в настройках личного кабинета.
Как зарегистрировать вебхук
Отправьте POST /me/webhooks с URL приёмника и списком событий. Endpoint должен быть публично доступен по HTTPS и отвечать 2xx в течение нескольких секунд.
curl -X POST https://api.paybot.kz/me/webhooks \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.kz/paybot/webhook",
"events": ["payment.completed", "payment.refunded"],
"description": "Основной приёмник магазина"
}'Тело запроса: url (обязателен, валидный HTTPS-URL до 2083 символов), events (обязателен, минимум одно событие), description (необязателен), secret (необязателен — если не передать, PayBot сгенерирует секрет сам).
Ответ — 201 Created с секретом в открытом виде. Секрет показывается ровно один раз — сохраните его сейчас, повторно прочитать его нельзя, только сгенерировать новый через ротацию.
{
"id": 42,
"url": "https://shop.example.kz/paybot/webhook",
"events": ["payment.completed", "payment.refunded"],
"description": "Основной приёмник магазина",
"is_active": true,
"created_at": "2026-07-16T09:20:00Z",
"secret": "whsec_3f9a1c8e2b7d4056a1c2e3f4b5d6c7e8",
"secret_version": 1,
"secret_revealed_at": "2026-07-16T09:20:00Z",
"failing_since": null
}Список вебхуков — GET /me/webhooks. В нём секрет уже не отдаётся, только secret_version, secret_revealed_at, а также поля здоровья доставки: failing_since, last_delivery_at, last_delivery_status.
Какие события бывают
PayBot шлёт вебхуки на следующие типы событий (список WEBHOOK_EVENTS в бэкенде):
| Событие | Когда отправляется |
|---|---|
payment.completed | платёж успешно оплачен (QR или счёт) |
payment.cancelled | платёж отменён (мерчантом или клиентом) |
payment.expired | истёк срок QR (~5 минут) или счёта (~24 часа) |
payment.refunded | по платежу выполнен возврат |
webhook.test | тестовая доставка через кнопку «Проверить» |
Если при создании передать неизвестное событие, вернётся 400 unknown_event — в тексте ошибки перечислены допустимые значения. Подписывайтесь только на то, что реально обрабатываете: лишние события создают ненужную нагрузку на ваш приёмник.
Как устроена доставка
Каждая доставка — это POST-запрос на ваш url с телом события в формате JSON и набором служебных заголовков.
| Заголовок | Значение |
|---|---|
X-Webhook-ID | уникальный идентификатор доставки (для дедупликации) |
X-Webhook-Event | тип события, например payment.completed |
X-Webhook-Timestamp | момент подписи, Unix-время в секундах |
X-Webhook-Signature | sha256= — HMAC-подпись тела |
User-Agent | PayBot-Webhooks/1.0 |
Content-Type | application/json |
Тело доставки — компактный JSON (без лишних пробелов) с конвертом события: id, type, created_at и полезная нагрузка в data.
{"id":"evt_a1b2c3d4","type":"payment.completed","created_at":"2026-07-16T09:25:11Z","data":{"operation_id":"op_7f3c9a12","type":"qr","amount":5000,"status":"paid","phone":"7017770001","sender_name":"АЙДАР К.","paid_at":"2026-07-16T09:25:10Z","transaction_id":"KZ00219384"}}Суммы — целые тенге (₸), даты — в UTC. Структура data зависит от типа события; закладывайтесь на новые поля и не падайте, если появится незнакомый ключ.
Проверка подписи вебхука
Подпись гарантирует, что запрос действительно от PayBot, а тело не подменено. Алгоритм: HMAC-SHA256, ключ — ваш webhook secret, сообщение — строка "{timestamp}.{body}", где timestamp берётся из заголовка X-Webhook-Timestamp, а body — точное сырое тело запроса (те самые байты, что пришли, без повторной сериализации). Результат — hex-строка, её сравнивают с частью после sha256= в заголовке X-Webhook-Signature.
Критично работать с сырым телом (raw body), а не с распарсенным и заново собранным объектом: любая перестановка ключей или добавленный пробел изменит подпись. Сравнение всегда делайте в постоянном времени (constant-time), чтобы не открывать тайминг-атаку на секрет.
Проверка на Python:
import hmac
import hashlib
def verify_signature(secret: str, timestamp: str, raw_body: bytes, signature_header: str) -> bool:
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
received = signature_header.split("sha256=", 1)[-1]
return hmac.compare_digest(expected, received)
# Во Flask/FastAPI берите request.get_data() / await request.body() — это сырые байтыПроверка на JavaScript (Node.js):
const crypto = require("crypto");
function verifySignature(secret, timestamp, rawBody, signatureHeader) {
const message = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(message, "utf8")
.digest("hex");
const received = signatureHeader.replace("sha256=", "");
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// В Express используйте express.raw({ type: "application/json" }),
// чтобы req.body был Buffer с исходными байтами, а не разобранным JSON== или === — используйте hmac.compare_digest (Python) или crypto.timingSafeEqual (Node), иначе появляется тайминг-утечка.Защита от повторной отправки (replay)
Даже валидно подписанный запрос могут перехватить и отправить повторно. Сверяйте X-Webhook-Timestamp с текущим временем и отклоняйте доставки старше, например, 5 минут — тогда перехваченный запрос быстро «протухнет».
import time
def is_fresh(timestamp: str, tolerance_seconds: int = 300) -> bool:
return abs(time.time() - int(timestamp)) <= tolerance_secondsОтдельно защищайтесь от легитимных дублей: PayBot может доставить одно событие несколько раз при ретраях. Дедуплицируйте по X-Webhook-ID (или по id из тела) и обрабатывайте каждое событие идемпотентно.
Полный пример приёмника
Собранный воедино endpoint делает четыре вещи по порядку: берёт сырое тело, проверяет свежесть по timestamp, сверяет подпись constant-time, дедуплицирует по X-Webhook-ID и только потом отвечает 200. Тяжёлую бизнес-логику выносите в очередь после ответа.
Приёмник на Python (Flask):
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_3f9a1c8e2b7d4056a1c2e3f4b5d6c7e8"
seen_ids = set() # в проде — Redis/БД, а не память процесса
@app.post("/paybot/webhook")
def paybot_webhook():
raw_body = request.get_data() # именно сырые байты
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
delivery_id = request.headers.get("X-Webhook-ID", "")
# 1. Защита от replay
if not timestamp or abs(time.time() - int(timestamp)) > 300:
abort(400, "stale timestamp")
# 2. Проверка подписи constant-time
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(WEBHOOK_SECRET.encode(), message, hashlib.sha256).hexdigest()
received = signature.split("sha256=", 1)[-1]
if not hmac.compare_digest(expected, received):
abort(401, "bad signature")
# 3. Дедупликация
if delivery_id in seen_ids:
return "", 200 # уже обработали — молча подтверждаем
seen_ids.add(delivery_id)
# 4. Быстрый 2xx, обработку — в очередь
event = request.get_json()
enqueue(event) # ваша фоновая задача
return "", 200Приёмник на JavaScript (Express):
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = "whsec_3f9a1c8e2b7d4056a1c2e3f4b5d6c7e8";
const seenIds = new Set(); // в проде — Redis/БД
app.post(
"/paybot/webhook",
express.raw({ type: "application/json" }), // req.body = Buffer с сырыми байтами
(req, res) => {
const rawBody = req.body;
const timestamp = req.header("X-Webhook-Timestamp") || "";
const signature = req.header("X-Webhook-Signature") || "";
const deliveryId = req.header("X-Webhook-ID") || "";
// 1. Replay
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("stale timestamp");
}
// 2. Подпись constant-time
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const received = signature.replace("sha256=", "");
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
// 3. Дедупликация
if (seenIds.has(deliveryId)) return res.sendStatus(200);
seenIds.add(deliveryId);
// 4. Быстрый 2xx
enqueue(JSON.parse(rawBody.toString("utf8")));
res.sendStatus(200);
}
);Статусы доставки
В журнале GET /me/webhooks/{id}/deliveries каждая доставка имеет status, число попыток attempts и, при неудаче, last_error.
| status | Смысл |
|---|---|
pending | доставка поставлена в очередь, ещё не отправлена |
delivered | получен ответ 2xx, доставка успешна |
failed | все попытки исчерпаны, ответ не 2xx или таймаут |
Поле delivered_at заполняется только для успешных доставок, last_error — короткое описание последнего сбоя (например HTTP 500 from endpoint или connection timeout). По этим данным строится «консоль доставок» уровня Stripe: видно, что именно ответил ваш сервер и на какой попытке всё упало.
Ретраи, backoff и авто-отключение
Если ваш приёмник ответил не 2xx или не ответил вовсе, PayBot повторяет доставку с экспоненциальным backoff — интервалы между попытками растут. Когда все попытки исчерпаны, вебхук помечается как проблемный: поле failing_since получает время начала сбоев, а после длительной серии неудач вебхук автоматически отключается (is_active: false), чтобы не долбить мёртвый endpoint.
Отвечайте 2xx сразу после того, как приняли событие, и обрабатывайте его асинхронно (очередь, фоновая задача). Если вы держите соединение открытым на время тяжёлой бизнес-логики, PayBot может посчитать доставку неуспешной по таймауту и начать ретраить.
Проверить журнал доставок можно через GET /me/webhooks/{id}/deliveries — это курсорный список.
curl https://api.paybot.kz/me/webhooks/42/deliveries \
-H "Authorization: Bearer <access_token>"{
"data": [
{
"id": 9001,
"event_id": "evt_a1b2c3d4",
"event_type": "payment.completed",
"status": "failed",
"attempts": 3,
"created_at": "2026-07-16T09:25:11Z",
"delivered_at": null,
"last_error": "HTTP 500 from endpoint"
}
],
"next_cursor": null,
"has_more": false
}О курсоре и обходе страниц — в разделе пагинация.
Ручной retry, тест и повторное включение
- •Повторить конкретную доставку:
POST /me/webhooks/{id}/deliveries/{delivery_id}/retry. - •Отправить тестовое событие
webhook.test:POST /me/webhooks/{id}/test. - •Включить обратно отключённый вебхук:
POST /me/webhooks/{id}/enable(сбрасываетfailing_since). - •Временно выключить:
POST /me/webhooks/{id}/disable.
Тест возвращает мини-консоль: что ответил ваш сервер.
curl -X POST https://api.paybot.kz/me/webhooks/42/test \
-H "Authorization: Bearer <access_token>"{
"status_code": 200,
"response_preview": "{\"ok\":true}",
"duration_ms": 214
}status_code равен 0, если PayBot вообще не смог достучаться до вашего сервера (транспортная ошибка: DNS, TLS, таймаут). response_preview — первые 500 символов тела ответа, duration_ms — сколько заняла попытка.
Ротация секрета вебхука
Если секрет мог утечь, замените его через POST /me/webhooks/{id}/rotate-secret. Вернётся новый secret (снова один раз) и увеличенный secret_version.
curl -X POST https://api.paybot.kz/me/webhooks/42/rotate-secret \
-H "Authorization: Bearer <access_token>"{
"id": 42,
"url": "https://shop.example.kz/paybot/webhook",
"events": ["payment.completed", "payment.refunded"],
"is_active": true,
"secret": "whsec_9e8d7c6b5a4039281706f5e4d3c2b1a0",
"secret_version": 2,
"secret_revealed_at": "2026-07-16T10:05:00Z",
"failing_since": null
}Чтобы ротация прошла без потери доставок, во время окна замены принимайте оба секрета: сначала проверяйте подпись новым, при неуспехе — старым, а после того как убедитесь, что все свежие доставки подписаны новой версией, удалите старый секрет из кода.
Лучшие практики
- •Обрабатывайте идемпотентно. Одно событие может прийти несколько раз — дедуплицируйте по
X-Webhook-IDи не начисляйте заказ дважды. - •Отвечайте
2xxбыстро. Приняли — вернули200, тяжёлую работу вынесите в очередь. Медленный ответ = ретраи и риск авто-отключения. - •Всегда проверяйте подпись constant-time-сравнением и работайте с сырым телом.
- •Защищайтесь от replay по
X-Webhook-Timestamp(окно ~5 минут). - •Не доверяйте телу как источнику истины о деньгах вслепую — для критичных операций сверяйтесь с
GET /v2/payments/{op_id}или статусом операции. - •Мониторьте
failing_sinceв списке вебхуков и настройте алерт, чтобы не пропустить авто-отключение.
FAQ
Чем вебхук отличается от журнала событий?
Вебхук — push: PayBot сам присылает событие вам в момент, когда оно произошло. Журнал событий GET /v2/events — pull: вы сами забираете накопленные события по запросу. Push удобнее для мгновенной реакции, pull — для восстановления после простоя приёмника, когда часть вебхуков могла не дойти.
Что делать, если пропустил вебхуки из-за падения сервера?
Поднимите приёмник и переберите пропущенное через GET /v2/events (курсорная лента всех событий аккаунта). Отдельные упавшие доставки можно повторить вручную через POST /me/webhooks/{id}/deliveries/{delivery_id}/retry, а весь вебхук — включить заново через /enable, если он ушёл в авто-отключение.
Почему подпись не сходится, хотя секрет верный?
Почти всегда причина в теле: вы подписываете уже распарсенный и заново сериализованный JSON вместо сырых байтов запроса. Берите raw body до любого JSON-парсинга и подписывайте строку "{timestamp}.{raw_body}" ровно теми байтами, что пришли.
Нужен ли HTTPS для приёмника?
Да. URL вебхука должен быть HTTPS — секрет и данные о платежах не должны ходить по открытому HTTP. Подпись защищает от подмены, но конфиденциальность обеспечивает именно TLS.
Как проверить вебхук в тестовом режиме?
Создайте вебхук и нажмите «Проверить» (POST /me/webhooks/{id}/test) — придёт событие webhook.test, а в ответе вы увидите код и первые 500 символов ответа вашего сервера. Подробнее про тестовый режим и sandbox.
Смежные разделы
- •Журнал событий — pull-лента всех событий аккаунта.
- •Идемпотентность — как не обработать одно событие дважды.
- •Коды ошибок — структура ответа при ошибке и обработка по категориям.