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

X-Hub API

X-Hub — платёжный шлюз для B2B-мерчантов. Принимаем рубли от российских клиентов через СБП, рассчитываемся с вами в USDT.

Base URL: https://api.x-hub.online/api/v1

Что получает мерчант

  • Приём платежей СБП без юр.лица в РФ
  • Выплаты в USDT (TRC20) на любой кошелёк
  • Webhook-уведомления о каждом изменении статуса
  • Идемпотентность, HMAC-подпись, ретраи из коробки
  • Markup-курс фиксируется на сутки (T+1 settlement)

Ключевые концепции

Термин Что это
Payment Заявка на приём рублей. Создаётся тобой, оплачивается клиентом через СБП
Webhook HTTP POST от X-Hub на твой URL при изменении статуса платежа
Settlement Ежедневная конвертация pending RUB → available USDT по фиксированному курсу
Withdrawal Запрос на вывод USDT с баланса на внешний кошелёк
Markup Твой процент сверх биржевого курса (договаривается при подключении)

Быстрый старт

Создадим первый платёж и получим webhook за 5 минут.

Что нужно

  • api_key — публичный идентификатор (UUID)
  • api_secret — секрет, хранится на твоём бэкенде
  • webhook_secret — для верификации webhook'ов
  • Публичный URL для webhook'ов (HTTPS, работающий 24/7)

Все три значения выдаёт команда X-Hub.

Храни api_secret и webhook_secret только на бэкенде

Никогда не отдавай клиенту/браузеру. При компрометации — запроси ротацию.

1. Создать платёж

curl -X POST https://api.x-hub.online/api/v1/payments \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "X-Api-Secret: YOUR_API_SECRET" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_rub": "500.00",
    "external_id": "order-12345",
    "payment_method": "sbp",
    "description": "VPN подписка 1 месяц"
  }'

Ответ:

{
  "id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "amount_rub": "500.00",
  "payment_url": "https://paywidget.org/abc-def-ghi",
  "expires_at": "2026-04-14T13:15:00Z"
}

Что дальше: редирект клиента на payment_url или встраивание iframe.

2. Клиент оплачивает

Клиент видит QR-код СБП, оплачивает через приложение банка. X-Hub получает подтверждение, меняет статус на paid.

3. Получить webhook

X-Hub делает POST на твой webhook_url с payload:

{
  "event": "payment.status_changed",
  "timestamp": "2026-04-14T13:05:12Z",
  "payment": {
    "id": "pay_550e8400-...",
    "status": "paid",
    "amount_rub": "500.00",
    "external_id": "order-12345"
  }
}

В заголовке X-Webhook-Signature — HMAC-SHA256 от сырого тела, ключ = твой webhook_secret. Как проверить подпись →

4. Проверить статус вручную

curl https://api.x-hub.online/api/v1/payments/pay_550e8400-... \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "X-Api-Secret: YOUR_API_SECRET"

Аутентификация

Все запросы к API (кроме health-check) требуют два заголовка:

Header Значение
X-Api-Key Публичный ID мерчанта (UUID)
X-Api-Secret Секрет мерчанта (64 hex символа)

При невалидных ключах — 401 Unauthorized.

Где хранить ключи

  • api_key — можно логировать, не секрет
  • api_secretтолько на твоём бэкенде в env/secret manager, не в git
  • webhook_secretтолько на твоём бэкенде, для проверки HMAC подписи webhook'ов

Правила хранения

  • Никогда не клади в git
  • Никогда не отдавай клиенту (браузер/мобилка)
  • Не пересылай в чатах
  • Логируй только api_key, не api_secret

Idempotency-Key

Обязательно для всех POST запросов, которые создают ресурсы.

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

Формат: UUID v4.

Как работает

  • При первом вызове — создаётся новый ресурс, ключ сохраняется в X-Hub на 24 часа
  • Повторный вызов с тем же ключом вернёт тот же результат (без дубликата)
  • Повторный вызов с тем же ключом но разным телом409 Conflict

Зачем

Предотвращает двойное списание при сетевых сбоях. Если твой POST упал по таймауту — ретрай с тем же ключом безопасен.

Плохо и хорошо

// ❌ НЕПРАВИЛЬНО: генерируем UUID на каждый retry
async function createPayment(amount) {
  return fetch('...', {
    headers: { 'Idempotency-Key': crypto.randomUUID() },  // каждый retry = новый UUID
  });
}

// ✅ ПРАВИЛЬНО: UUID генерируется один раз на операцию
async function createPayment(orderId, amount) {
  const idempotencyKey = `order-${orderId}-${amount}`;  // детерминированный
  return fetch('...', {
    headers: { 'Idempotency-Key': idempotencyKey },
  });
}

Rate Limiting

  • 60 запросов в минуту на api_key
  • При превышении — 429 Too Many Requests

Заголовки ответа:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1776166800

Если получил 429 — жди до X-RateLimit-Reset (Unix timestamp) и ретрай.


Платежи

Жизненный цикл

pending → processing → paid → settled
                   └→ amount_mismatch
                   └→ expired
                   └→ failed
Статус Значение
pending Создан, ждёт оплаты. Истекает через 15 минут
processing Клиент начал оплату (сканировал QR)
paid Платёж подтверждён банком. USDT в pending_rub
settled Прошёл settlement. USDT на available_balance
amount_mismatch Клиент заплатил не ту сумму. Требует разбора
expired 15 минут истекли, клиент не оплатил
failed Отказ банка / отмена

Создать платёж — POST /payments

Headers

Header Обяз.
X-Api-Key
X-Api-Secret
Idempotency-Key UUID v4
Content-Type application/json

Body

{
  "amount_rub": "500.00",
  "external_id": "order-12345",
  "payment_method": "sbp",
  "description": "VPN подписка 1 месяц",
  "metadata": {"user_id": "usr_abc", "plan": "premium"}
}
Поле Тип Обяз. Описание
amount_rub string Decimal, 2 знака. Минимум "1.00"
external_id string Твой ID заказа (для сопоставления)
payment_method string Сейчас только "sbp"
description string До 256 символов, видит клиент
metadata object Произвольный JSON, до 4KB. Возвращается в webhook и GET

Ответ 201

{
  "id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "amount_rub": "500.00",
  "actual_amount_rub": null,
  "amount_usdt": null,
  "external_id": "order-12345",
  "description": "VPN подписка 1 месяц",
  "payment_method": "sbp",
  "payment_url": "https://paywidget.org/abc-def",
  "expires_at": "2026-04-14T13:15:00Z",
  "created_at": "2026-04-14T13:00:00Z",
  "paid_at": null,
  "settled_at": null,
  "metadata": {"user_id": "usr_abc", "plan": "premium"}
}

Что делать: редирект клиента на payment_url.

Получить платёж — GET /payments/:id

curl https://api.x-hub.online/api/v1/payments/pay_550e8400-... \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

Ответ — тот же формат что и при создании, но с обновлёнными полями:

  • status — актуальный
  • actual_amount_rub — сколько реально заплатил клиент
  • amount_usdt — после settlement, сколько начислено
  • paid_at, settled_at — временные метки

Ошибка 404 PAYMENT_NOT_FOUND — если платёж не принадлежит твоему мерчанту.

Список платежей — GET /payments

Query параметры

Параметр Тип По умолчанию
page int 1
per_page int 20 (макс 100)
status string — (все)
from ISO8601
to ISO8601

Пример:

GET /api/v1/payments?status=paid&from=2026-04-01T00:00:00Z&per_page=50

Ответ

{
  "data": [
    { "id": "pay_...", "status": "paid", "amount_rub": "500.00" }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total": 245,
    "total_pages": 5
  }
}

Edge cases

amount_mismatch

Клиент заплатил больше или меньше указанного. В webhook и GET:

{
  "status": "amount_mismatch",
  "amount_rub": "500.00",
  "actual_amount_rub": "490.00"
}

Что делать: связаться с клиентом / X-Hub команда поможет разрулить. Деньги не теряются — они на нашем транзитном счёте.

expired

Клиент не оплатил за 15 минут. Платёж финальный, повторить нельзя (создавай новый).

failed

Банк отказал (подозрение на фрод, недостаток средств). Финальный статус.


Webhooks

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

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

  • HTTPS обязательно (HTTP не принимаем в проде)
  • Отвечает HTTP 200 в пределах 10 секунд
  • Всё кроме 2xx считается ошибкой → ретрай
  • Идемпотентная обработка (webhook может прийти дважды)

Формат payload

{
  "event": "payment.status_changed",
  "timestamp": "2026-04-14T13:05:12Z",
  "payment": {
    "id": "pay_550e8400-e29b-41d4-a716-446655440000",
    "status": "paid",
    "amount_rub": "500.00",
    "actual_amount_rub": "500.00",
    "amount_usdt": null,
    "external_id": "order-12345",
    "description": "VPN подписка 1 месяц",
    "metadata": {"user_id": "usr_abc"},
    "created_at": "2026-04-14T13:00:00Z",
    "paid_at": "2026-04-14T13:05:12Z",
    "settled_at": null
  }
}

События

event Когда
payment.status_changed Любое изменение статуса платежа
withdrawal.status_changed Любое изменение статуса выплаты

Смотри payment.status / withdrawal.status чтобы понять текущее состояние.

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

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

X-Webhook-Signature: sha256=a1b2c3d4...

Вычисляется как HMAC-SHA256(raw_body, webhook_secret), hex encoding.

Важно

  • Проверяй на сыром body до парсинга JSON, иначе подпись не сойдётся
  • Если не совпало — отказывай 401, не обрабатывай
  • Используй crypto.timingSafeEqual (или аналог) — защита от timing attack

Node.js (Express)

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

const app = express();

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    )) {
      return res.status(401).send('Invalid signature');
    }

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

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

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', '')
    expected = 'sha256=' + hmac.new(
        os.environ['XHUB_WEBHOOK_SECRET'].encode(),
        raw,
        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['payment']['status'] == 'paid':
        grant_access_to(event['payment']['external_id'])

    return 'OK', 200

PHP

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, 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['payment']['status'] === 'paid') {
    grantAccessTo($event['payment']['external_id']);
}

http_response_code(200);
echo 'OK';

Retry-стратегия

Если твой endpoint не ответил 200:

Попытка Задержка
1 сразу
2 +10 сек
3 +1 мин
4 +5 мин
5 +15 мин
6 +1 час
7 +6 часов
8-10 +24 часа

Всего 10 попыток за ~3 суток. Потом webhook помечается как FAILED и требует ручного разбора.

Идемпотентность

Webhook может прийти дважды (retry после таймаута). Твой обработчик должен быть идемпотентным.

async function handleWebhook(event) {
  const paymentId = event.payment.id;

  const alreadyProcessed = await db.webhookLog.findUnique({
    where: {
      paymentId_status: { paymentId, status: event.payment.status }
    }
  });

  if (alreadyProcessed) return;

  await db.webhookLog.create({
    data: { paymentId, status: event.payment.status }
  });

  await grantAccess(event.payment.external_id);
}

Чек-лист

  • [ ] Endpoint отвечает за < 10 секунд
  • [ ] HTTPS с валидным сертификатом
  • [ ] Проверка X-Webhook-Signature на сыром body
  • [ ] Идемпотентная обработка по payment.id + status
  • [ ] Отвечаешь 200 после успешной обработки (не до)
  • [ ] Логируешь все приходящие webhooks для отладки
  • [ ] Обработчик не падает на неизвестных event типах

Баланс и курсы

GET /balance

Текущий баланс мерчанта.

curl https://api.x-hub.online/api/v1/balance \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

Ответ

{
  "available_usdt": "1245.32100000",
  "reserved_usdt": "0.00000000",
  "pending_rub": "50000.00",
  "total_settled_usdt": "15678.90000000",
  "total_withdrawn_usdt": "14433.58000000",
  "updated_at": "2026-04-14T12:00:00Z"
}
Поле Что значит
available_usdt Доступно для вывода прямо сейчас
reserved_usdt Зарезервировано под активный withdrawal
pending_rub Уже принятые рубли, ждут settlement (T+1)
total_settled_usdt Всего получено USDT за всё время
total_withdrawn_usdt Всего выведено за всё время

Settlement T+1

Что такое T+1: платежи, прошедшие до 12:00 UTC сегодня (T), конвертируются в USDT завтра (T+1) в 12:00 UTC по фиксированному курсу на момент settlement.

Зачем:

  • Фиксируем курс на весь батч — нет спекуляций на колебаниях
  • Успеваем проверить все callback'и от банка
  • Есть буфер на возвраты/диспуты

Пример:

9 апр 10:00 — платёж 1000₽ принят (pending_rub += 1000)
10 апр 12:00 — settlement: 1000₽ / 83.3595 = 11.996 USDT → available_usdt
10 апр 12:01 — можно выводить

GET /rates

Informational курс RUB/USDT. Не settlement — только для справки/калькулятора.

curl https://api.x-hub.online/api/v1/rates \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

Ответ

{
  "pair": "RUB/USDT",
  "rate_rapira": "79.37",
  "rate_merchant": "83.3385",
  "markup_percent": "5",
  "updated_at": "2026-04-14T12:00:00Z",
  "note": "Informational rate. Final rate is fixed at T+1 settlement."
}

Используй rate_merchant в калькуляторе

Клиент платит 1000₽ → тебе начислится 1000 / rate_merchant USDT (минус флуктуации до settlement).

Формула settlement

Для платежа на A рублей при markup_percent = M, курсе Rapira на момент settlement R:

merchant_rate = R × (1 + M / 100)
merchant_usdt = A / merchant_rate
netpay_fee_usdt = (A / R) × 0.038    # 3.8% комиссия провайдера
xhub_fee_usdt = (A / R) - merchant_usdt - netpay_fee_usdt

Пример

Клиент платит 1000₽, R = 79.37 (курс Rapira на T+1), markup = 5%:

merchant_rate = 79.37 × 1.05 = 83.3385
merchant_usdt = 1000 / 83.3385 = 11.99919 USDT ← тебе
netpay_fee = (1000 / 79.37) × 0.038 = 0.47876 USDT
xhub_fee = 12.59919 - 11.99919 - 0.47876 = 0.12124 USDT (1.2% эффективная)

Выплаты (Withdrawals)

Вывод USDT с баланса мерчанта на внешний кошелёк.

Минимальная сумма: 10 USDT

Создать выплату — POST /withdrawals

curl -X POST https://api.x-hub.online/api/v1/withdrawals \
  -H "X-Api-Key: ..." \
  -H "X-Api-Secret: ..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usdt": "500.00000000",
    "wallet_address": "TYourTronWalletHereTRC20Abc123",
    "network": "TRC20"
  }'

Body

Поле Тип Обяз. Описание
amount_usdt string Decimal, 8 знаков. Минимум "10.00000000"
wallet_address string Адрес получателя
network string Сейчас только "TRC20"

Ответ 201

{
  "id": "wd_abc123-...",
  "status": "pending",
  "amount_usdt": "500.00000000",
  "wallet_address": "TYour...",
  "network": "TRC20",
  "tx_hash": null,
  "created_at": "2026-04-14T13:00:00Z"
}

available_usdt мерчанта сразу уменьшается. Сумма перетекает в reserved_usdt до завершения.

Если баланса не хватает — 409 INSUFFICIENT_BALANCE.

Жизненный цикл выплаты

pending → processing → completed
                   └→ failed
Статус
pending Принято, ждёт ручного одобрения команды X-Hub
processing Отправляется в сеть TRC20
completed Транзакция подтверждена, tx_hash в ответе
failed Отказ (вернётся в available_usdt)

Время обработки: обычно в течение 24 часов в рабочие дни.

Получить выплату — GET /withdrawals/:id

curl https://api.x-hub.online/api/v1/withdrawals/wd_abc123-... \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

Ответ:

{
  "id": "wd_abc123-...",
  "status": "completed",
  "amount_usdt": "500.00000000",
  "wallet_address": "TYour...",
  "network": "TRC20",
  "tx_hash": "c1a2b3...",
  "created_at": "2026-04-14T13:00:00Z",
  "completed_at": "2026-04-14T13:05:00Z"
}

Список выплат — GET /withdrawals

GET /api/v1/withdrawals?page=1&per_page=20&status=completed

Webhook выплаты

При смене статуса выплаты — событие withdrawal.status_changed:

{
  "event": "withdrawal.status_changed",
  "timestamp": "2026-04-14T13:05:00Z",
  "withdrawal": {
    "id": "wd_abc123-...",
    "status": "completed",
    "amount_usdt": "500.00000000",
    "tx_hash": "c1a2b3..."
  }
}

Верификация подписи — как для платежей.


Ошибки

Формат

{
  "error": {
    "code": "PAYMENT_NOT_FOUND",
    "message": "Payment with id 'pay_xxx' not found",
    "details": {}
  }
}

Таблица

HTTP Code Когда Что делать
400 VALIDATION_ERROR Невалидные параметры запроса Проверь details, исправь запрос
400 MISSING_IDEMPOTENCY_KEY Нет заголовка Idempotency-Key на POST Добавь UUID v4
401 UNAUTHORIZED Неверные X-Api-Key / X-Api-Secret Проверь ключи
404 PAYMENT_NOT_FOUND Платёж не существует / чужой Проверь ID
404 WITHDRAWAL_NOT_FOUND Выплата не существует / чужая Проверь ID
409 IDEMPOTENCY_CONFLICT Тот же Idempotency-Key с другим телом Используй новый ключ или то же тело
409 INSUFFICIENT_BALANCE Мало USDT для выплаты Дождись settlement или уменьши сумму
429 RATE_LIMIT_EXCEEDED Превышен лимит 60 req/min Жди до X-RateLimit-Reset
500 INTERNAL_ERROR Серверная ошибка Ретрай через 30 сек, если повторяется — пиши команде
502 PROVIDER_ERROR Ошибка внешнего провайдера (СБП/биржа) Ретрай через 30-60 сек

Validation errors

При 400 VALIDATION_ERROR в details — массив проблем:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": [
      {"path": "amount_rub", "message": "Must be a decimal string with up to 2 decimal places"},
      {"path": "payment_method", "message": "Must be 'sbp'"}
    ]
  }
}

Retry-стратегия

Код Retry? Как
4xx нет Fix запрос, ретрай не поможет (кроме 409 idempotency — новый ключ)
429 да Жди X-RateLimit-Reset
500 да Exponential backoff: 1s, 2s, 4s, 8s, max 5 попыток
502 да Exponential backoff: 5s, 15s, 60s

Tip

Всегда используй Idempotency-Key на ретраях POST — иначе можно создать дубль платежа.


Примеры: Node.js

Рабочий код на Node.js 18+ с нативным fetch и crypto. Без зависимостей.

Клиент X-Hub

// xhub-client.js
import crypto from 'crypto';

const BASE_URL = 'https://api.x-hub.online/api/v1';

export async function createPayment({ amountRub, externalId, description, metadata }) {
  const res = await fetch(`${BASE_URL}/payments`, {
    method: 'POST',
    headers: {
      'X-Api-Key': process.env.XHUB_API_KEY,
      'X-Api-Secret': process.env.XHUB_API_SECRET,
      'Idempotency-Key': crypto.randomUUID(),
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount_rub: amountRub,
      external_id: externalId,
      payment_method: 'sbp',
      description,
      metadata,
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`X-Hub ${res.status}: ${err.error?.code}: ${err.error?.message}`);
  }

  return res.json();
}

export async function getPayment(id) {
  const res = await fetch(`${BASE_URL}/payments/${id}`, {
    headers: {
      'X-Api-Key': process.env.XHUB_API_KEY,
      'X-Api-Secret': process.env.XHUB_API_SECRET,
    },
  });
  if (!res.ok) throw new Error(`X-Hub ${res.status}`);
  return res.json();
}

export async function getBalance() {
  const res = await fetch(`${BASE_URL}/balance`, {
    headers: {
      'X-Api-Key': process.env.XHUB_API_KEY,
      'X-Api-Secret': process.env.XHUB_API_SECRET,
    },
  });
  return res.json();
}

Webhook receiver (Express)

// webhook-server.js
import express from 'express';
import crypto from 'crypto';
import { grantAccess } from './business-logic.js';

const app = express();
const processedWebhooks = new Set(); // в проде — Redis/DB

app.post(
  '/webhook/xhub',
  express.raw({ type: 'application/json', limit: '100kb' }),
  async (req, res) => {
    try {
      const signature = req.headers['x-webhook-signature'];
      const expected =
        'sha256=' +
        crypto
          .createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
          .update(req.body)
          .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'));
      const dedupeKey = `${event.payment?.id || event.withdrawal?.id}:${
        event.payment?.status || event.withdrawal?.status
      }`;

      if (processedWebhooks.has(dedupeKey)) {
        return res.status(200).send('Already processed');
      }
      processedWebhooks.add(dedupeKey);

      if (event.event === 'payment.status_changed') {
        const p = event.payment;
        if (p.status === 'paid') {
          await grantAccess(p.external_id, p.metadata);
        }
      }

      res.status(200).send('OK');
    } catch (err) {
      console.error('Webhook error:', err);
      res.status(500).send('Internal error');
    }
  },
);

app.listen(8080);

Примеры: Python

Python 3.10+ с requests и flask.

pip install requests flask

Клиент X-Hub

# xhub_client.py
import os, uuid, requests

BASE_URL = 'https://api.x-hub.online/api/v1'
HEADERS = {
    'X-Api-Key': os.environ['XHUB_API_KEY'],
    'X-Api-Secret': os.environ['XHUB_API_SECRET'],
}


class XHubError(Exception):
    pass


def create_payment(amount_rub, external_id, description, metadata=None):
    r = requests.post(
        f'{BASE_URL}/payments',
        headers={
            **HEADERS,
            'Idempotency-Key': str(uuid.uuid4()),
            'Content-Type': 'application/json',
        },
        json={
            'amount_rub': amount_rub,
            'external_id': external_id,
            'payment_method': 'sbp',
            'description': description,
            'metadata': metadata or {},
        },
        timeout=15,
    )
    if not r.ok:
        raise XHubError(f'X-Hub {r.status_code}: {r.json().get("error")}')
    return r.json()


def get_balance():
    r = requests.get(f'{BASE_URL}/balance', headers=HEADERS, timeout=10)
    r.raise_for_status()
    return r.json()

Webhook receiver (Flask)

# webhook_server.py
import os, hmac, hashlib, json
from flask import Flask, request, abort

app = Flask(__name__)
processed = set()  # в проде — Redis / БД

WEBHOOK_SECRET = os.environ['XHUB_WEBHOOK_SECRET']


@app.route('/webhook/xhub', methods=['POST'])
def webhook():
    raw = request.get_data()

    sig = request.headers.get('X-Webhook-Signature', '')
    expected = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode(), raw, hashlib.sha256
    ).hexdigest()

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

    event = json.loads(raw)
    obj = event.get('payment') or event.get('withdrawal') or {}
    dedupe_key = f"{obj.get('id')}:{obj.get('status')}"

    if dedupe_key in processed:
        return 'OK', 200
    processed.add(dedupe_key)

    if event['event'] == 'payment.status_changed':
        p = event['payment']
        if p['status'] == 'paid':
            grant_access(p['external_id'], p.get('metadata', {}))

    return 'OK', 200


def grant_access(external_id, metadata):
    print(f'Granting access for {external_id}')


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

Поддержка

Вопросы, проблемы — пиши в ваш чат с командой X-Hub.