--- title: "Уведомления" url: "https://docs.x-hub.online/webhooks/" description: "Требования к приёмнику, формат уведомления, напоминание о хэше перевода клиенту и проверка подписи." relatedDocuments: - "https://docs.x-hub.online/webhooks/delivery/" - "https://docs.x-hub.online/exchange/kyc/" - "https://docs.x-hub.online/exchange/" - "https://docs.x-hub.online/payments/refunds/" - "https://docs.x-hub.online/exchange-terminal/" - "https://docs.x-hub.online/payments/" --- # Webhooks X-Hub уведомляет твой бэкенд об изменениях платежей и выплат через HTTP POST на твой `webhook_url`. ## Полный пример запроса { #polnyy-primer-zaprosa } Ниже, в описании каждого события, показано только тело — заголовки всегда одни и те же. Вот как выглядит доставка целиком: ```http 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"} } ``` Что означает каждый заголовок и как проверить подпись — [Верификация подписи](#verify-signature). ## Требования к endpoint - **HTTPS** обязательно (HTTP не принимаем в проде) - Отвечает **HTTP 200** в пределах **10 секунд** - Ретраи — при `5xx`, `429` и сетевых ошибках/таймаутах. Ответ `4xx` (кроме `429`) помечает **обычное** событие как `FAILED` сразу, без ретраев; для **денежных** событий `4xx` не окончателен — см. [Retry-стратегия](./delivery.md#retry-strategiya). `3xx`-редиректы не следуются и терминальны всегда - Endpoint должен быть **прямым URL**: редирект (даже `301` на тот же хост с `www`) считается провалом доставки - Идемпотентная обработка (webhook может прийти дважды) ## Формат payload Payload — **плоский объект** (не вложенный). Все поля на верхнем уровне: ```json { "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 (списание по подписке не прошло — на счёте не хватило денег): ```json { "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): ```json { "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 часа после оплаты, не больше трёх. Хэш подтверждает получение перевода клиентом и используется при споре, платёж и выплата его не ждут ([подробнее](#webhook-delivery-proof-reminder)) | | `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` — статус проверки клиента { #webhook-kyc-status } Приходит, когда проверка клиента пройдена (см. [KYC](../exchange/kyc.md)): фоновая проверка подтвердила верификацию, начатую через `POST /kyc/register`. Сейчас это единственный исход — отказ в проверке этим событием **не приходит**, его видно только опросом [`GET /exchange-clients`](../exchange/index.md#get-exchange-clients). ```json { "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`](../exchange/index.md#get-exchange-clients)) | | `phone` | Телефон клиента, формат `+7XXXXXXXXXX` | | `status` | Сегодня всегда `verified`. В схеме зарезервировано и `failed` — обработчик должен его переносить, но пока оно не отправляется | | `previous_status` | Сегодня всегда `pending` (в схеме зарезервировано `failed`) | | `verified_at` | Когда верификация подтверждена (ISO-8601) | ### `refund.*` — заявочные возвраты { #webhook-refunds } Четыре события про судьбу **заявки** на возврат — своей, созданной через [`POST /refunds`](../payments/refunds.md#refund-create), или заведённой командой X-Hub по обращению клиента. Payload у всех четырёх одной формы, обработчик один: ```json { "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`; приходит только при включённой [совместимости вебхуков возврата](#webhook-refund-compat) | | `metadata` | **Опционально.** Твой `metadata` платежа — в том же виде, что в `payment.status_changed` этого платежа (`null`, если его нет); приходит только при включённой совместимости | | `shop_id` / `shop_name` | Магазин, которому принадлежит платёж и его возврат — см. [Магазин в теле события](#webhook-shop) | ### Имя плательщика в событии платежа { #webhook-payer-name } В уведомлении `payment.status_changed` может прийти поле: | Поле | Что значит | |---|---| | `payer_name` | Имя плательщика, как его сообщил банк — строка | Поля может не быть: оно приходит не у каждого платежа, и когда имени нет, ключа в теле **нет** (не `null`) — уведомления таких платежей не изменились. Когда имя есть, оно приходит с уведомлением о статусе `paid` или позже и дальше повторяется в каждом уведомлении этого платежа. Если в уведомлении `paid` поля не было, перечитай платёж запросом `GET /api/v1/payments/:id` — отдельного уведомления о появлении имени нет. Значение отдаётся как получено от банка. Если проверяешь тело строгой схемой — добавь в неё поле как необязательную строку (актуальная схема — `https://docs.x-hub.online/webhooks/schema.json`). ### Напоминание о хэше перевода клиенту { #webhook-delivery-proof-reminder } Событие `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` ([что значат причины](../exchange-terminal/index.md#step-delivery-proof)) | | `timestamp` | Когда сформировано напоминание | | `shop_id` / `shop_name` | Магазин платежа — как в [остальных событиях платежа](#webhook-shop) | ```json { "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`). #### Как принять напоминание и ответить хэшем { #webhook-delivery-proof-reminder-examples } Порядок такой: 1. Проверь подпись, как в разделе [Верификация подписи](#verify-signature). 2. Найди в своём хранилище хэш перевода по `payment_id`. 3. Хэш есть — передай его запросом `POST /api/v1/payments/:id/delivery-proof` ([описание шага](../exchange-terminal/index.md#step-delivery-proof)) и ответь `200`. 4. Хэша ещё нет — просто ответь `200` и жди следующего напоминания. Повторная отправка того же хэша безопасна, поэтому повторная доставка напоминания (тот же `X-Webhook-Id`) ничего не ломает. Если запрос с хэшем не прошёл из-за сбоя сети или `5xx`, ответь `5xx`: уведомление придёт повторно по [обычным ретраям](./delivery.md#retry-strategiya). Остальные отказы повтором не лечатся — запиши их и ответь `200`. Если `status` равен `rejected` и `reason` не `not_found_or_unconfirmed`, тот же хэш отправлять заново бессмысленно: проверь его вручную. **Node.js (Express)** ```javascript 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)** ```python 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** ```php-inline $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'; ``` ### Магазин в теле события { #webhook-shop } Если у тебя несколько магазинов, в `payment.status_changed` и во всех четырёх `refund.*` приходят два поля: | Поле | Что значит | |---|---| | `shop_id` | Магазин, которому принадлежит платёж (и его возврат) — «голый» UUID, как `refund_id`: без префикса и без `pay_` | | `shop_name` | Название того же магазина, как оно задано в кабинете | Оба поля присутствуют **всегда**: у платежей, сделанных до появления магазинов, они указывают на магазин по умолчанию, а если магазина нет вовсе — приходят как `null`. Ни одно прежнее поле не переименовано и не убрано. Своего адреса и секрета у магазина нет: уведомления всех магазинов приходят на адрес аккаунта из «Разработчикам → Вебхуки» и подписываются секретом аккаунта. Исключение одно — адрес, переданный в самом платеже (`webhook_url` при создании): уведомления этого платежа уходят туда, подпись та же. Различай магазины по `shop_id`. Что означает каждый исход и что по нему делать — [Исход: четыре события](../payments/refunds.md#refund-outcome). !!! warning "Отличия от остального API — читай внимательно" - **`payment_id` здесь БЕЗ префикса `pay_`** — это «голый» UUID, в отличие от `payment.status_changed` и REST-ответов. Сопоставляя с платежом, отрежь префикс на своей стороне. То же и с `refund_id`. Обратно в REST голый id можно подставлять как есть: ручка принимает оба вида. - **`payment.status_changed` по заявочному возврату по умолчанию НЕ приходит** — ни при частичном, ни при полном. Даже когда сумма возвратов покрывает платёж целиком и он уходит в `refunded`, единственное уведомление об этом — `refund.completed`. Исключение — [совместимость вебхуков возврата](#webhook-refund-compat): с ней при полном возврате статусное уведомление приходит как раньше. - `status` приходит в **ВЕРХНЕМ** регистре (`COMPLETED` / `REJECTED` / `FAILED` / `REVERSED`), а не в нижнем, как `status` платежа. - Причина не передаётся ни в одном из четырёх — спрашивай у команды X-Hub. `refund.completed` и `refund.reversed` — денежные события (медленная лестница ретраев). `refund.rejected` и `refund.failed` — нет: денег они не двигали. Это **не** то же самое, что `payment.status_changed` со `status: refunded`: тот приходит, когда деньги отозвал банк-эквайер сам, без заявки с твоей стороны (см. [Возврат после успешной оплаты](../payments/index.md#payments)). ### Совместимость вебхуков возврата { #webhook-refund-compat } До 1 августа 2026 заявочных возвратов не существовало: возврат делала команда X-Hub по обращению, платёж менял статус на `refunded`, и мерчанту уходило обычное `payment.status_changed`. 1 августа появились заявки и события `refund.*`, а статусное уведомление по заявочному возврату перестало уходить (см. [историю](#webhook-changelog)). Если твой бэкенд уже привык к старому виду уведомлений, для твоего мерчанта можно включить **режим совместимости** — через поддержку 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 в цикле. ```json { "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. !!! info "Что означают комбинации" - `reason=api` + высокий `error_rate_1h` — наш бэкенд возвращает ошибки при попытке создать платёж. - `reason=completion` + низкий `completion_rate_1h` — создание работает, QR генерируется, но клиенты массово не доводят оплату до конца (банк/SBP таймаут, платёжный backend не подтверждает). - `reason=both` — оба сигнала: вероятен полный коллапс шлюза. Используй эту информацию для своего мониторинга — мы сами устраняем причину со своей стороны и пришлём `operational` когда восстановим. ## Верификация подписи { #verify-signature } В каждом 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). !!! danger "Важно" - Используй **оба** заголовка: `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) ```javascript 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'); } ); ``` !!! warning "Обработчик обязан быть `async`" Внутри есть `await grantAccessTo(...)`. Без `async` перед `(req, res)` файл не распарсится — `SyntaxError` на старте, а не в рантайме. ### Python (Flask) ```python 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 ```php-inline $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'; ``` ## История изменений контракта { #webhook-changelog } | Дата | Что изменилось | |---|---| | 1 августа 2026 | Появились заявочные возвраты и события `refund.completed` / `refund.rejected` / `refund.failed` / `refund.reversed` (форма — см. [`refund.*`](#webhook-refunds)). `payment.status_changed(refunded)` по возврату с заявкой больше не отправляется — уведомлением о возврате стал `refund.completed` | | 15 сентября 2026 | [Совместимость вебхуков возврата](#webhook-refund-compat): по запросу мерчанта администратор включает режим, в котором при полном заявочном возврате дополнительно уходит `payment.status_changed(refunded)` (прежний вид уведомления), а в `refund.*` добавляются опциональные `external_id` и `metadata` | | 22 сентября 2026 | [Магазин в теле события](#webhook-shop): в `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-shop): адрес и секрет подписи вебхука — одни на аккаунт, своих у магазина нет. Уведомления всех магазинов уходят на адрес аккаунта (или на `webhook_url` платежа) и подписываются секретом аккаунта; `shop_id` и `shop_name` в теле — как раньше | | 5 октября 2026 | [Имя плательщика в событии платежа](#webhook-payer-name): на аккаунтах, для которых имя плательщика предоставляется, в уведомления `payment.status_changed` добавлено необязательное поле `payer_name` — имя плательщика, как его сообщил банк. Приходит со статусом `paid` или позже; когда имени нет, поля в теле нет (не `null`). У остальных платежей тело не изменилось | | 6 октября 2026 | [Напоминание о хэше перевода клиенту](#webhook-delivery-proof-reminder): новое событие `payment.delivery_proof_reminder` — по оплаченному платежу терминала с обменом при оплате без переданного хэша через 24, 48 и 72 часа после оплаты (не больше трёх раз). Включено по умолчанию; в `payment.status_changed` ничего не добавлено |