Перейти к содержанию

Webhooks

X-Hub уведомляет твой бэкенд об изменениях платежей и выплат через HTTP POST на твой webhook_url.

Полный пример запроса

Ниже, в описании каждого события, показано только тело — заголовки всегда одни и те же. Вот как выглядит доставка целиком:

POST /your/webhook/path HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-Webhook-Id: 5b3fa1e2-9c8d-4f16-8a7b-b2e6f0c9d123
X-Webhook-Timestamp: 2026-04-14T13:05:12.000Z
X-Webhook-Signature: sha256=a1b2c3d4...

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "paid",
  "previous_status": "processing",
  "amount_rub": "500.00",
  "actual_amount_rub": "500.00",
  "amount_usdt": null,
  "payment_method": "sbp",
  "refund_pending": false,
  "timestamp": "2026-04-14T13:05:12Z",
  "metadata": {"user_id": "usr_abc"}
}

Что означает каждый заголовок и как проверить подпись — Верификация подписи.

Требования к endpoint

  • HTTPS обязательно (HTTP не принимаем в проде)
  • Отвечает HTTP 200 в пределах 10 секунд
  • Ретраи — при 5xx, 429 и сетевых ошибках/таймаутах. Ответ 4xx (кроме 429) помечает обычное событие как FAILED сразу, без ретраев; для денежных событий 4xx не окончателен — см. Retry-стратегия. 3xx-редиректы не следуются и терминальны всегда
  • Endpoint должен быть прямым URL: редирект (даже 301 на тот же хост с www) считается провалом доставки
  • Идемпотентная обработка (webhook может прийти дважды)

Формат payload

Payload — плоский объект (не вложенный). Все поля на верхнем уровне:

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "paid",
  "previous_status": "processing",
  "amount_rub": "500.00",
  "actual_amount_rub": "500.00",
  "amount_usdt": null,
  "payment_method": "sbp",
  "refund_pending": false,
  "timestamp": "2026-04-14T13:05:12Z",
  "metadata": {"user_id": "usr_abc"}
}

payment_method в payload'е — тот же словарь, которым ты создаёшь платёж: "sbp", "card" (оплата картой, если приём карт подключён твоему аккаунту) и "recurring" у списания по подписке.

Пример failed webhook (списание по подписке не прошло — на счёте не хватило денег):

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "failed",
  "previous_status": "pending",
  "amount_rub": "299.00",
  "actual_amount_rub": null,
  "amount_usdt": null,
  "payment_method": "recurring",
  "subscription_id": "sub_9f1c...",
  "period_ref": "2026-08",
  "refund_pending": false,
  "failure_reason": "insufficient_funds",
  "timestamp": "2026-08-09T04:26:11Z",
  "metadata": {"user_id": "usr_abc"}
}

Пример refunded webhook (банк отозвал платёж после settlement):

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "refunded",
  "previous_status": "settled",
  "amount_rub": "500.00",
  "actual_amount_rub": "500.00",
  "amount_usdt": "6.20000000",
  "payment_method": "sbp",
  "refund_pending": false,
  "timestamp": "2026-05-13T16:12:06Z",
  "metadata": {"user_id": "usr_abc"}
}

События

event Когда
payment.status_changed Любое изменение статуса платежа (в т.ч. списаний по подписке, payment_method: "recurring")
withdrawal.status_changed Любое изменение статуса выплаты
exchange.status_changed Любое изменение статуса обмена (RUB↔USDT)
subscription.status_changed Изменение статуса подписки: подтверждение мандата, пауза, отмена, а также готовность ссылки привязки (см. раздел про подписки ниже)
refund.completed Возврат по платежу исполнен — деньги ушли клиенту
refund.rejected Заявка на возврат отклонена — деньги не двигались
refund.failed Возврат сорвался при исполнении — деньги не ушли, клиент всё ещё ждёт
refund.reversed Исполненный возврат откачен — платёж снова живой, деньги снова твои
payment.delivery_proof_reminder По оплаченному платежу терминала с обменом при оплате ещё не передан хэш перевода клиенту: напоминание через 24, 48 и 72 часа после оплаты, не больше трёх. Хэш подтверждает получение перевода клиентом и используется при споре, платёж и выплата его не ждут (подробнее)
kyc.status_changed Изменение статуса KYC-верификации клиента
gateway.status_changed Изменение статуса платёжного шлюза X-Hub: operational / degraded / down
subscription.charge_exhausted Бюджет повторов списания по подписке исчерпан — до даты next_allowed_at новых попыток не будет; подписка остаётся активной. Приходит всегда: в настройках событий кабинета не отключается

Смотри payment.status / withdrawal.status / exchange.status чтобы понять текущее состояние. X-Webhook-Timestamp — ISO-8601 в UTC.

kyc.status_changed — статус проверки клиента

Приходит, когда проверка клиента пройдена (см. KYC): фоновая проверка подтвердила верификацию, начатую через POST /kyc/register. Сейчас это единственный исход — отказ в проверке этим событием не приходит, его видно только опросом GET /exchange-clients.

{
  "event": "kyc.status_changed",
  "client_id": "3f2b8c1a-9d4e-4a7b-8c5d-1e2f3a4b5c6d",
  "phone": "+79991234567",
  "status": "verified",
  "previous_status": "pending",
  "verified_at": "2026-08-01T10:00:00.000Z",
  "timestamp": "2026-08-01T10:00:05.000Z"
}
Поле Что значит
client_id Идентификатор клиента (тот же, что id в GET /exchange-clients)
phone Телефон клиента, формат +7XXXXXXXXXX
status Сегодня всегда verified. В схеме зарезервировано и failed — обработчик должен его переносить, но пока оно не отправляется
previous_status Сегодня всегда pending (в схеме зарезервировано failed)
verified_at Когда верификация подтверждена (ISO-8601)

refund.* — заявочные возвраты

Четыре события про судьбу заявки на возврат — своей, созданной через POST /refunds, или заведённой командой X-Hub по обращению клиента. Payload у всех четырёх одной формы, обработчик один:

{
  "event": "refund.completed",
  "payment_id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_id": "9c8d4f16-8a7b-b2e6-f0c9-d1235b3fa1e2",
  "amount_rub": "500.00",
  "status": "COMPLETED",
  "external_id": "order-10241",
  "metadata": {"order_url": "https://shop.example/order/10241"}
}
Поле Что значит
payment_id Платёж, по которому делался возврат
refund_id Идентификатор заявки на возврат. На один платёж заявок может быть несколько — уведомления по ним независимы
amount_rub Сумма именно этой заявки, а не всего платежа
status Исход: COMPLETED / REJECTED / FAILED / REVERSED — по одному на каждое событие
external_id Опционально. Номер заказа из твоего external_id; приходит только при включённой совместимости вебхуков возврата
metadata Опционально. Твой metadata платежа — в том же виде, что в payment.status_changed этого платежа (null, если его нет); приходит только при включённой совместимости
shop_id / shop_name Магазин, которому принадлежит платёж и его возврат — см. Магазин в теле события

Имя плательщика в событии платежа

В уведомлении payment.status_changed может прийти поле:

Поле Что значит
payer_name Имя плательщика, как его сообщил банк — строка

Поля может не быть: оно приходит не у каждого платежа, и когда имени нет, ключа в теле нет (не null) — уведомления таких платежей не изменились.

Когда имя есть, оно приходит с уведомлением о статусе paid или позже и дальше повторяется в каждом уведомлении этого платежа. Если в уведомлении paid поля не было, перечитай платёж запросом GET /api/v1/payments/:id — отдельного уведомления о появлении имени нет.

Значение отдаётся как получено от банка. Если проверяешь тело строгой схемой — добавь в неё поле как необязательную строку (актуальная схема — https://docs.x-hub.online/webhooks/schema.json).

Напоминание о хэше перевода клиенту

Событие payment.delivery_proof_reminder приходит по оплаченному платежу терминала с обменом при оплате, по которому ты ещё не передал хэш перевода клиенту. Хэш подтверждает, что клиент получил перевод, и используется, если по платежу возникает спор; напоминание приходит, чтобы хэш не потерялся. Платёж, расчёт и выплата хэша не ждут.

Когда приходит: через 24, 48 и 72 часа после оплаты (paid_at), не больше трёх раз на платёж. Каждое напоминание приходит вскоре после своего срока. Пропущенное напоминание позже не отправляется — придёт следующее.

Когда не приходит: хэш уже передан и не отклонён (статусы accepted, in_review, awaiting_confirmations, confirmed, confirmed_amount_differs), платёж не в статусе paid или settled, платёж тестового режима.

Поле Что значит
event Всегда payment.delivery_proof_reminder
payment_id Платёж, с префиксом pay_ — его же подставляй в POST /api/v1/payments/:id/delivery-proof
external_id Твой номер заказа или null
paid_at Когда платёж оплачен (ISO 8601, UTC)
reminder Номер напоминания: 1, 2 или 3
status Состояние хэша: waiting — не передан, rejected — последний переданный отклонён
reason Только при rejected: not_found_or_unconfirmed, already_used или invalid (что значат причины)
timestamp Когда сформировано напоминание
shop_id / shop_name Магазин платежа — как в остальных событиях платежа
{
  "event": "payment.delivery_proof_reminder",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "paid_at": "2026-10-05T13:07:41.000Z",
  "reminder": 1,
  "status": "waiting",
  "timestamp": "2026-10-06T13:10:02.000Z",
  "shop_id": null,
  "shop_name": null
}

Событие включено по умолчанию. Отключить его можно там же, где остальные: «Разработчикам → Вебхуки», группа «Платежи», отметка «Напоминание о хэше перевода клиенту». Если ты уже настраивал набор событий вручную, отметь его в этом наборе — иначе оно не придёт. Если проверяешь тело строгой схемой, добавь это событие (актуальная схема — https://docs.x-hub.online/webhooks/schema.json).

Как принять напоминание и ответить хэшем

Порядок такой:

  1. Проверь подпись, как в разделе Верификация подписи.
  2. Найди в своём хранилище хэш перевода по payment_id.
  3. Хэш есть — передай его запросом POST /api/v1/payments/:id/delivery-proof (описание шага) и ответь 200.
  4. Хэша ещё нет — просто ответь 200 и жди следующего напоминания.

Повторная отправка того же хэша безопасна, поэтому повторная доставка напоминания (тот же X-Webhook-Id) ничего не ломает. Если запрос с хэшем не прошёл из-за сбоя сети или 5xx, ответь 5xx: уведомление придёт повторно по обычным ретраям. Остальные отказы повтором не лечатся — запиши их и ответь 200. Если status равен rejected и reason не not_found_or_unconfirmed, тот же хэш отправлять заново бессмысленно: проверь его вручную.

Node.js (Express)

import crypto from 'crypto';
import express from 'express';

const app = express();

// Твоё хранилище: payment_id -> { network, txHash }. Замени на чтение из своей базы.
const transferHashes = new Map();
async function findTransferHash(paymentId) {
  return transferHashes.get(paymentId) ?? null;
}

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
      .update(timestamp + '.' + req.body.toString('utf-8'))
      .digest('hex');
    if (
      !signature ||
      signature.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    ) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf-8'));
    if (event.event === 'payment.delivery_proof_reminder') {
      if (event.status === 'rejected' && event.reason !== 'not_found_or_unconfirmed') {
        console.warn('Хэш отклонён, проверь вручную', event.payment_id, event.reason);
        return res.status(200).send('OK');
      }

      const proof = await findTransferHash(event.payment_id);
      if (!proof) {
        // Хэша ещё нет: отвечаем 200 и ждём следующего напоминания.
        return res.status(200).send('OK');
      }

      let answer;
      try {
        answer = await fetch(
          `https://api.x-hub.online/api/v1/payments/${event.payment_id}/delivery-proof`,
          {
            method: 'POST',
            headers: {
              'X-Api-Key': process.env.XHUB_API_KEY,
              'X-Api-Secret': process.env.XHUB_API_SECRET,
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ network: proof.network, tx_hash: proof.txHash }),
          },
        );
      } catch {
        return res.status(502).send('Retry later'); // сеть: пусть напоминание придёт повторно
      }
      if (answer.status >= 500) return res.status(502).send('Retry later');
      if (!answer.ok) {
        // 400 / 404 / 409 повтором не лечатся: запиши отказ и разберись вручную.
        console.warn('Хэш не принят', event.payment_id, answer.status, await answer.text());
      }
    }

    res.status(200).send('OK');
  }
);

Python (Flask)

import os, hmac, hashlib
import requests
from flask import Flask, request, abort

app = Flask(__name__)

# Твоё хранилище: payment_id -> {"network": ..., "tx_hash": ...}. Замени на чтение из своей базы.
transfer_hashes = {}

def find_transfer_hash(payment_id):
    return transfer_hashes.get(payment_id)

@app.route('/webhook', methods=['POST'])
def webhook():
    raw = request.get_data()
    sig = request.headers.get('X-Webhook-Signature', '')
    timestamp = request.headers.get('X-Webhook-Timestamp', '')
    expected = 'sha256=' + hmac.new(
        os.environ['XHUB_WEBHOOK_SECRET'].encode(),
        timestamp.encode() + b'.' + raw,
        hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401, 'Invalid signature')

    event = request.get_json()
    if event['event'] == 'payment.delivery_proof_reminder':
        if event['status'] == 'rejected' and event.get('reason') != 'not_found_or_unconfirmed':
            app.logger.warning('Хэш отклонён, проверь вручную: %s %s', event['payment_id'], event.get('reason'))
            return 'OK', 200

        proof = find_transfer_hash(event['payment_id'])
        if proof is None:
            # Хэша ещё нет: отвечаем 200 и ждём следующего напоминания.
            return 'OK', 200

        try:
            answer = requests.post(
                f"https://api.x-hub.online/api/v1/payments/{event['payment_id']}/delivery-proof",
                headers={
                    'X-Api-Key': os.environ['XHUB_API_KEY'],
                    'X-Api-Secret': os.environ['XHUB_API_SECRET'],
                },
                json={'network': proof['network'], 'tx_hash': proof['tx_hash']},
                timeout=8,
            )
        except requests.RequestException:
            return 'Retry later', 502  # сеть: пусть напоминание придёт повторно
        if answer.status_code >= 500:
            return 'Retry later', 502
        if not answer.ok:
            # 400 / 404 / 409 повтором не лечатся: запиши отказ и разберись вручную.
            app.logger.warning('Хэш не принят: %s %s %s', event['payment_id'], answer.status_code, answer.text)

    return 'OK', 200

PHP

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, getenv('XHUB_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($raw, true);
if ($event['event'] === 'payment.delivery_proof_reminder') {
    if ($event['status'] === 'rejected' && ($event['reason'] ?? null) !== 'not_found_or_unconfirmed') {
        error_log('Хэш отклонён, проверь вручную: ' . $event['payment_id']);
        http_response_code(200);
        exit('OK');
    }

    // Твоё хранилище: вернуть ['network' => 'TRC20', 'tx_hash' => '...'] или null.
    $proof = findTransferHash($event['payment_id']);
    if ($proof === null) {
        // Хэша ещё нет: отвечаем 200 и ждём следующего напоминания.
        http_response_code(200);
        exit('OK');
    }

    $ch = curl_init('https://api.x-hub.online/api/v1/payments/' . $event['payment_id'] . '/delivery-proof');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 8,
        CURLOPT_HTTPHEADER => [
            'X-Api-Key: ' . getenv('XHUB_API_KEY'),
            'X-Api-Secret: ' . getenv('XHUB_API_SECRET'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(['network' => $proof['network'], 'tx_hash' => $proof['tx_hash']]),
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false || $status >= 500) {
        http_response_code(502); // сеть или сбой: пусть напоминание придёт повторно
        exit('Retry later');
    }
    if ($status >= 400) {
        // 400 / 404 / 409 повтором не лечатся: запиши отказ и разберись вручную.
        error_log('Хэш не принят: ' . $event['payment_id'] . ' ' . $status . ' ' . $body);
    }
}

http_response_code(200);
echo 'OK';

Магазин в теле события

Если у тебя несколько магазинов, в payment.status_changed и во всех четырёх refund.* приходят два поля:

Поле Что значит
shop_id Магазин, которому принадлежит платёж (и его возврат) — «голый» UUID, как refund_id: без префикса и без pay_
shop_name Название того же магазина, как оно задано в кабинете

Оба поля присутствуют всегда: у платежей, сделанных до появления магазинов, они указывают на магазин по умолчанию, а если магазина нет вовсе — приходят как null. Ни одно прежнее поле не переименовано и не убрано.

Своего адреса и секрета у магазина нет: уведомления всех магазинов приходят на адрес аккаунта из «Разработчикам → Вебхуки» и подписываются секретом аккаунта. Исключение одно — адрес, переданный в самом платеже (webhook_url при создании): уведомления этого платежа уходят туда, подпись та же. Различай магазины по shop_id.

Что означает каждый исход и что по нему делать — Исход: четыре события.

Отличия от остального API — читай внимательно

  • payment_id здесь БЕЗ префикса pay_ — это «голый» UUID, в отличие от payment.status_changed и REST-ответов. Сопоставляя с платежом, отрежь префикс на своей стороне. То же и с refund_id. Обратно в REST голый id можно подставлять как есть: ручка принимает оба вида.
  • payment.status_changed по заявочному возврату по умолчанию НЕ приходит — ни при частичном, ни при полном. Даже когда сумма возвратов покрывает платёж целиком и он уходит в refunded, единственное уведомление об этом — refund.completed. Исключение — совместимость вебхуков возврата: с ней при полном возврате статусное уведомление приходит как раньше.
  • status приходит в ВЕРХНЕМ регистре (COMPLETED / REJECTED / FAILED / REVERSED), а не в нижнем, как status платежа.
  • Причина не передаётся ни в одном из четырёх — спрашивай у команды X-Hub.

refund.completed и refund.reversed — денежные события (медленная лестница ретраев). refund.rejected и refund.failed — нет: денег они не двигали.

Это не то же самое, что payment.status_changed со status: refunded: тот приходит, когда деньги отозвал банк-эквайер сам, без заявки с твоей стороны (см. Возврат после успешной оплаты).

Совместимость вебхуков возврата

До 1 августа 2026 заявочных возвратов не существовало: возврат делала команда X-Hub по обращению, платёж менял статус на refunded, и мерчанту уходило обычное payment.status_changed. 1 августа появились заявки и события refund.*, а статусное уведомление по заявочному возврату перестало уходить (см. историю).

Если твой бэкенд уже привык к старому виду уведомлений, для твоего мерчанта можно включить режим совместимости — через поддержку X-Hub, включается администратором по запросу. С ним:

  • при исполнении заявки, когда возврат покрывает платёж целиком и платёж уходит в refunded, тебе дополнительно приходит payment.status_changed со status: refunded — то же тело, что у банковского возврата: pay_-id, твой metadata, previous_status и прочее. При частичной заявке (платёж остаётся в прежнем статусе) статусное уведомление не приходит — «refunded» при живом платеже было бы ложью; refund.completed приходит в обоих случаях;
  • во все четыре события refund.* добавляются твои external_id и metadata платежа (поля опциональные — см. таблицу выше).

Что НЕ меняется: подпись, порядок доставки, ретраи, фильтр событий (если ты отписан от payment.status_changed в настройках событий — статусное уведомление и с совместимостью не придёт), повторная доставка одного события не дублируется. Выключение режима возвращает прежний вид уведомлений.

gateway.status_changed — статус платёжного шлюза

Когда платёжный шлюз X-Hub деградирует или восстанавливается, мы шлём отдельный webhook. Это позволяет тебе подключить наш статус к своему мониторингу/алертам без необходимости опрашивать /health endpoint в цикле.

{
  "event": "gateway.status_changed",
  "status": "down",
  "error_rate_1h": 0,
  "completion_rate_1h": 0,
  "reason": "completion",
  "since": "2026-05-15T04:00:00.000Z",
  "timestamp": "2026-05-15T04:00:32.144Z"
}

Поля:

  • status — текущий статус шлюза:
    • operational — всё работает штатно
    • degraded — повышенный процент ошибок или сниженная доля успешных оплат
    • down — шлюз временно недоступен / клиенты не могут завершить оплаты
  • error_rate_1h — процент API-ошибок за последний час (0-100)
  • completion_rate_1h — процент успешных оплат за последний час PAID / (PAID+CANCELLED+EXPIRED+FAILED), 0-100. Может быть null — это значит за час было слишком мало финализированных платежей (< 5), чтобы посчитать значимую метрику
  • reason — что вызвало смену статуса:
    • api — повышенный процент ошибок на нашей API-стороне (создание платежей)
    • completion — клиенты сканируют QR, но платежи не доходят (низкая конверсия)
    • both — оба сигнала одновременно
  • since — ISO-8601 timestamp когда текущий статус впервые установился

Webhook отправляется только при переходе через границу operational ↔ degraded/down. Промежуточные смены degraded ↔ down не дублируются. После отправки webhook'а на пару (мерчант, статус) применяется cooldown 15 минут — защита от flapping.

Что означают комбинации

  • reason=api + высокий error_rate_1h — наш бэкенд возвращает ошибки при попытке создать платёж.
  • reason=completion + низкий completion_rate_1h — создание работает, QR генерируется, но клиенты массово не доводят оплату до конца (банк/SBP таймаут, платёжный backend не подтверждает).
  • reason=both — оба сигнала: вероятен полный коллапс шлюза.

Используй эту информацию для своего мониторинга — мы сами устраняем причину со своей стороны и пришлём operational когда восстановим.

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

В каждом webhook есть три заголовка:

X-Webhook-Id: 5b3fa1e2-9c8d-4f16-8a7b-b2e6f0c9d123
X-Webhook-Signature: sha256=a1b2c3d4...
X-Webhook-Timestamp: 2026-04-14T13:05:12.000Z
  • X-Webhook-Id — уникальный UUID доставки. Используй его для idempotent-обработки на своей стороне (если получил webhook с тем же X-Webhook-Id повторно — это ретрай, не новое событие)
  • X-Webhook-Signature / X-Webhook-Timestamp — используются для проверки подписи

Подпись вычисляется как:

HMAC-SHA256(key: webhook_secret, data: timestamp + "." + raw_body)

Где timestamp — значение из заголовка X-Webhook-Timestamp, raw_body — сырое тело запроса (до парсинга JSON).

Важно

  • Используй оба заголовка: X-Webhook-Timestamp и X-Webhook-Signature
  • Проверяй на сыром body до парсинга JSON, иначе подпись не сойдётся
  • Данные для подписи: timestamp + "." + raw_body (конкатенация через точку)
  • Если не совпало — отказывай 401, не обрабатывай
  • Используй crypto.timingSafeEqual (или аналог) — защита от timing attack
  • Защита от replay: отклоняй webhook'и с X-Webhook-Timestamp старше 5 минут от текущего времени — даже если подпись валидна. Злоумышленник с перехваченным webhook не сможет переиграть его позже.

Node.js (Express)

import crypto from 'crypto';
import express from 'express';

const app = express();

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    // Данные для подписи: timestamp + "." + raw_body
    const signatureData = timestamp + '.' + req.body.toString('utf-8');
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
      .update(signatureData)
      .digest('hex');

    // timingSafeEqual бросает RangeError, если длины буферов различаются,
    // — поэтому сначала проверяем наличие и длину, и только потом сравниваем.
    if (
      !signature ||
      signature.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    ) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf-8'));
    if (event.event === 'payment.status_changed' && event.status === 'paid') {
      await grantAccessTo(event.external_id);
    }

    res.status(200).send('OK');
  }
);

Обработчик обязан быть async

Внутри есть await grantAccessTo(...). Без async перед (req, res) файл не распарсится — SyntaxError на старте, а не в рантайме.

Python (Flask)

import os, hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    raw = request.get_data()
    sig = request.headers.get('X-Webhook-Signature', '')
    timestamp = request.headers.get('X-Webhook-Timestamp', '')

    # Данные для подписи: timestamp + "." + raw_body
    signature_data = timestamp.encode() + b'.' + raw
    expected = 'sha256=' + hmac.new(
        os.environ['XHUB_WEBHOOK_SECRET'].encode(),
        signature_data,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(sig, expected):
        abort(401, 'Invalid signature')

    event = request.get_json()
    if event['event'] == 'payment.status_changed' and event['status'] == 'paid':
        grant_access_to(event['external_id'])

    return 'OK', 200

PHP

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';

// Данные для подписи: timestamp + "." + raw_body
$signatureData = $timestamp . '.' . $raw;
$expected = 'sha256=' . hash_hmac('sha256', $signatureData, getenv('XHUB_WEBHOOK_SECRET'));

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

$event = json_decode($raw, true);
if ($event['event'] === 'payment.status_changed' && $event['status'] === 'paid') {
    grantAccessTo($event['external_id']);
}

http_response_code(200);
echo 'OK';

История изменений контракта

Дата Что изменилось
1 августа 2026 Появились заявочные возвраты и события refund.completed / refund.rejected / refund.failed / refund.reversed (форма — см. refund.*). payment.status_changed(refunded) по возврату с заявкой больше не отправляется — уведомлением о возврате стал refund.completed
15 сентября 2026 Совместимость вебхуков возврата: по запросу мерчанта администратор включает режим, в котором при полном заявочном возврате дополнительно уходит payment.status_changed(refunded) (прежний вид уведомления), а в refund.* добавляются опциональные external_id и metadata
22 сентября 2026 Магазин в теле события: в payment.status_changed и refund.* добавлены shop_id и shop_name (у истории без магазина — null). Прежние поля не переименованы и не убраны
23 сентября 2026 Исправление: actual_amount_rub в payment.status_changed равен тому, что отдаёт GET /api/v1/payments/:id — у отказа и отмены null (денег не было), у оплаты — полученная сумма. Карточный отказ банка теперь всегда failed с failure_reason: declined_by_bank (раньше иногда cancelled без причины). amount_rub в refund.* — всегда с двумя знаками ("1500.00", не "1500")
29 сентября 2026 Магазин в теле события: адрес и секрет подписи вебхука — одни на аккаунт, своих у магазина нет. Уведомления всех магазинов уходят на адрес аккаунта (или на webhook_url платежа) и подписываются секретом аккаунта; shop_id и shop_name в теле — как раньше
5 октября 2026 Имя плательщика в событии платежа: на аккаунтах, для которых имя плательщика предоставляется, в уведомления payment.status_changed добавлено необязательное поле payer_name — имя плательщика, как его сообщил банк. Приходит со статусом paid или позже; когда имени нет, поля в теле нет (не null). У остальных платежей тело не изменилось
6 октября 2026 Напоминание о хэше перевода клиенту: новое событие payment.delivery_proof_reminder — по оплаченному платежу терминала с обменом при оплате без переданного хэша через 24, 48 и 72 часа после оплаты (не больше трёх раз). Включено по умолчанию; в payment.status_changed ничего не добавлено