ДокументацияВведение
Коды ошибок

Интеграция

Webhooks

События платежей с HMAC-подписью

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 в течение нескольких секунд.

bash
Скачать
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 с секретом в открытом виде. Секрет показывается ровно один раз — сохраните его сейчас, повторно прочитать его нельзя, только сгенерировать новый через ротацию.

json
Скачать
{
  "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-Signaturesha256= — HMAC-подпись тела
User-AgentPayBot-Webhooks/1.0
Content-Typeapplication/json

Тело доставки — компактный JSON (без лишних пробелов) с конвертом события: id, type, created_at и полезная нагрузка в data.

json
Скачать
{"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:

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):

javascript
Скачать
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 минут — тогда перехваченный запрос быстро «протухнет».

python
Скачать
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):

python
Скачать
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):

javascript
Скачать
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 — это курсорный список.

bash
Скачать
curl https://api.paybot.kz/me/webhooks/42/deliveries \
  -H "Authorization: Bearer <access_token>"
json
Скачать
{
  "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.

Тест возвращает мини-консоль: что ответил ваш сервер.

bash
Скачать
curl -X POST https://api.paybot.kz/me/webhooks/42/test \
  -H "Authorization: Bearer <access_token>"
json
Скачать
{
  "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.

bash
Скачать
curl -X POST https://api.paybot.kz/me/webhooks/42/rotate-secret \
  -H "Authorization: Bearer <access_token>"
json
Скачать
{
  "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.

Смежные разделы