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, не в gitwebhook_secret— только на твоём бэкенде, для проверки HMAC подписи webhook'ов
Правила хранения
- Никогда не клади в git
- Никогда не отдавай клиенту (браузер/мобилка)
- Не пересылай в чатах
- Логируй только
api_key, неapi_secret
Idempotency-Key¶
Обязательно для всех POST запросов, которые создают ресурсы.
Формат: 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
Заголовки ответа:
Если получил 429 — жди до X-RateLimit-Reset (Unix timestamp) и ретрай.
Платежи¶
Жизненный цикл¶
| Статус | Значение |
|---|---|
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 | — |
Пример:
Ответ¶
{
"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:
Что делать: связаться с клиентом / 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 есть заголовок:
Вычисляется как 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¶
Текущий баланс мерчанта.
Ответ¶
{
"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 — только для справки/калькулятора.
Ответ¶
{
"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 |
Принято, ждёт ручного одобрения команды 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¶
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.
Клиент 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.