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).
Как принять напоминание и ответить хэшем
Порядок такой:
- Проверь подпись, как в разделе Верификация подписи.
- Найди в своём хранилище хэш перевода по
payment_id. - Хэш есть — передай его запросом
POST /api/v1/payments/:id/delivery-proof(описание шага) и ответь200. - Хэша ещё нет — просто ответь
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— используются для проверки подписи
Подпись вычисляется как:
Где 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 ничего не добавлено |