Возвраты
Возврат — это операция над уже успешным платежом: деньги, которые клиент заплатил, уходят обратно ему, а соответствующая сумма списывается с твоего баланса.
Возврат в X-Hub — заявка, а не мгновенная операция. Ты её создаёшь, мы исполняем, исход приходит вебхуком. Синхронного «вернул и получил ответ» не бывает нигде в отрасли, и наш API этого не обещает.
Ручка возвратов включается по запросу
/api/v1/refunds включается вашему аккаунту по обращению в X-Hub. Пока не включена — она отвечает 404, а возврат создаётся кнопкой в кабинете. В песочнице возвраты работают полностью и доходят до completed сами — интеграцию проверяй там, не на живых деньгах.
Что доступно
| Задача | Как |
|---|---|
| Начать возврат | POST /refunds или кнопка на платеже в кабинете |
| Отменить свою заявку | POST /refunds/:id/cancel — пока её не взяли в работу |
| Посмотреть заявку и остаток по платежу | GET /refunds/:id |
| Список заявок | GET /refunds (фильтры по платежу, статусу, своему external_id) |
| Узнать, что возврат идёт | Поле refund_pending в GET /payments/:id и в webhook payment.status_changed |
| Узнать исход | Вебхуки refund.completed / refund.rejected / refund.failed / refund.reversed |
| Узнать, что деньги отозвал сам банк | Webhook payment.status_changed со status: refunded |
Создать возврат — POST /refunds
Idempotency-Key обязателен. Обрыв связи на этом запросе — обычное дело, и без ключа ретрай твоего HTTP-клиента завёл бы вторую заявку: клиент получил бы деньги дважды. С ключом повтор возвращает ту же заявку и 200 вместо 201.
curl -X POST https://api.x-hub.online/api/v1/refunds \
-H "X-Api-Key: $XHUB_API_KEY" \
-H "X-Api-Secret: $XHUB_API_SECRET" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{
"payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
"reason": "Клиент отказался от заказа",
"external_id": "order-42-refund"
}'
import { randomUUID } from 'node:crypto';
const res = await fetch('https://api.x-hub.online/api/v1/refunds', {
method: 'POST',
headers: {
'X-Api-Key': process.env.XHUB_API_KEY,
'X-Api-Secret': process.env.XHUB_API_SECRET,
// Один ключ на одну попытку возврата. При ретрае — ТОТ ЖЕ ключ,
// иначе получится второй возврат.
'Idempotency-Key': randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
payment_id: payment.id,
reason: 'Клиент отказался от заказа',
external_id: `order-${order.id}-refund`,
}),
});
const refund = await res.json();
import os, uuid, requests
res = requests.post(
"https://api.x-hub.online/api/v1/refunds",
headers={
"X-Api-Key": os.environ["XHUB_API_KEY"],
"X-Api-Secret": os.environ["XHUB_API_SECRET"],
# Тот же ключ при ретрае — иначе будет второй возврат.
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"payment_id": payment_id,
"reason": "Клиент отказался от заказа",
"external_id": f"order-{order_id}-refund",
},
timeout=30,
)
refund = res.json()
Ответ 201 (или 200 на повторе того же ключа):
{
"id": "ref_9f8e7d6c-5b4a-4321-9876-0123456789ab",
"payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
"external_id": "order-42-refund",
"amount_rub": "1000.00",
"status": "requested",
"reason": "Клиент отказался от заказа",
"remaining_rub": "0.00",
"created_at": "2026-08-17T10:00:00.000Z",
"decided_at": null
}
Поля запроса:
| Поле | Обяз. | Что это |
|---|---|---|
payment_id |
да | id платежа, с префиксом pay_ или без |
reason |
да | Причина, до 500 символов. Видна тебе и нам, клиенту не показывается |
amount_rub |
нет | Частичная сумма возврата. Можно прислать несколько заявок подряд по одному платежу, пока их сумма укладывается в остаток (remaining_rub); превышение остатка — отказ. Без поля возвращается весь остаток |
external_id |
нет | Твоя ссылка на заказ. Мы её только храним и отдаём обратно — ищи по ней в GET /refunds |
status в REST — в нижнем регистре (requested, executing, completed, rejected, cancelled, failed, reversed). В вебхуках он исторически верхний — см. ниже.
Если платёжная сеть отказала в исполнении, заявка не закрывается: мы разбираемся и повторяем. Всё это время она остаётся executing и отменить её нельзя (409 CANNOT_CANCEL). Исход приходит одним из событий ниже.
remaining_rub — сколько ещё можно вернуть по этому платежу после учёта этой заявки. Частичная заявка уменьшает остаток на свою сумму, а не обнуляет его — можно продолжать возвращать частями, пока остаток не закончится; полная заявка обнуляет остаток.
Коды ошибок:
| Код | HTTP | Что случилось |
|---|---|---|
MISSING_IDEMPOTENCY_KEY |
400 | Не прислан Idempotency-Key |
VALIDATION_ERROR |
400 | Ключ не UUID, либо тело запроса не прошло проверку |
IDEMPOTENCY_KEY_REUSED |
409 | Тот же ключ с другими параметрами. Это не ретрай, а ошибка — новый возврат требует нового ключа |
NOT_FOUND |
404 | Платежа нет, он чужой или из другой среды (тест/бой) |
NOT_REFUNDABLE |
400 | Платёж не в возвратном статусе |
PARTIAL_DISABLED |
400 | Прислана сумма, а частичные возвраты сейчас недоступны. Без суммы возвращается весь остаток |
PARTIAL_BEFORE_SETTLEMENT |
400 | Частичный возврат до ежедневного расчёта недоступен для этого способа оплаты — только полный; после расчёта частичный доступен |
OVER_REMAINING |
400 | Сумма больше остатка по платежу; без суммы — по платежу уже есть заявки на весь остаток |
INVALID_AMOUNT |
400 | Сумма нулевая или отрицательная |
LIMIT_PER_PAYMENT |
409 | Больше 10 заявок на один платёж |
Idempotency-Key — это UUID, а не твой номер заказа
Ключ обязан быть UUID (randomUUID() / uuid4()); order-42-refund вернёт 400 VALIDATION_ERROR. Свою ссылку на заказ клади в external_id — она и хранится, и ищется в GET /refunds.
Ключ живёт внутри своей среды: один и тот же UUID в песочнице и в бою — две разные заявки, не склеиваются.
Отменить заявку — POST /refunds/:id/cancel
Пока заявку не взяли в работу, её можно отменить самому — заводить обращение в поддержку из-за опечатки не нужно.
curl -X POST https://api.x-hub.online/api/v1/refunds/ref_9f8e.../cancel \
-H "X-Api-Key: $XHUB_API_KEY" \
-H "X-Api-Secret: $XHUB_API_SECRET"
Возвращает заявку со status: cancelled. Если её уже взяли в работу — 409 CANNOT_CANCEL: деньги в этот момент могут уже двигаться, и отменить их нельзя. Заявка чужая, из другой среды или несуществующая — 404 REFUND_NOT_FOUND.
Найти заявки — GET /refunds
curl "https://api.x-hub.online/api/v1/refunds?payment_id=pay_550e...&status=completed" \
-H "X-Api-Key: $XHUB_API_KEY" \
-H "X-Api-Secret: $XHUB_API_SECRET"
Параметры: page (до 250000), per_page (до 100), status, payment_id, external_id. Ответ — { "data": [...], "pagination": {...} }, как у платежей. remaining_rub в списке не приходит — за ним GET /refunds/:id.
status — одно из значений REST-контракта выше (requested, executing, completed, rejected, cancelled, failed, reversed), регистр не важен. Незнакомое значение — 400 VALIDATION_ERROR с перечнем допустимых, фильтр никогда не игнорируется молча.
Прочитать заявку — GET /refunds/:id
curl https://api.x-hub.online/api/v1/refunds/ref_9f8e7d6c-5b4a-4321-9876-0123456789ab \
-H "X-Api-Key: $XHUB_API_KEY" \
-H "X-Api-Secret: $XHUB_API_SECRET"
Отдаёт ту же форму, что и создание, — и это единственное место, где вместе с заявкой приходит remaining_rub, остаток по платежу. В списке его нет намеренно: он считается запросом на каждую строку.
Id принимается в обоих видах — ref_9f8e... и голый 9f8e.... Это не послабление, а необходимость: вебхук refund.* исторически несёт refund_id без префикса, и подставлять его сюда надо как есть, без обработки на твоей стороне.
Чужая заявка, заявка из другой среды (песочница против боя) и несуществующая дают один и тот же 404 REFUND_NOT_FOUND. По ответу не отличить «нет такой» от «не твоя» — так и задумано.
Пока возврат идёт — refund_pending
С момента создания заявки платёж отдаётся с "refund_pending": true. Основной status при этом не меняется — платёж всё ещё paid или settled, потому что деньги клиенту ещё не вернулись, а заявка может быть и отклонена.
{
"payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
"status": "settled",
"refund_pending": true,
"amount_rub": "1000.00"
}
Не жди нового значения status как признака начавшегося возврата — ориентируйся на этот флаг. Поле присутствует всегда, у обычного платежа оно false.
Исход — четыре события
У заявки четыре терминальных исхода, и путать их дорого:
| Событие | Что произошло | Что делать |
|---|---|---|
refund.completed |
Деньги ушли клиенту | Отозвать услугу / закрыть заказ возвратом |
refund.rejected |
Мы решили не возвращать | Заказ остаётся оплаченным |
refund.failed |
Мы пытались, платёжная сеть отказала, и повторять больше не будем. Деньги НЕ ушли, клиент всё ещё ждёт | Разобраться и создать заявку заново — остаток платежа сорвавшаяся попытка не занимает |
refund.reversed |
Исполненный возврат откачен: платёж снова живой, деньги снова твои | Вернуть заказ в оплаченное состояние |
Общее для всех четырёх — payload одной формы, так что обработчик один, разбирается по status:
payment_idиrefund_idприходят без префиксов — «голый» UUID, в отличие отpay_.../ref_...в REST-ответах. Голый id можно подставлять в REST как есть, ручка принимает оба вида;statusприходит в верхнем регистре:COMPLETED/REJECTED/FAILED/REVERSED(в REST — нижний);amount_rub— сумма этой заявки, а не всего платежа;- на один платёж заявок может быть несколько, и уведомления по ним независимы;
- причина отказа не передаётся ни в одном из событий — спрашивай у команды X-Hub.
refund.completed и refund.reversed — денежные события, они ретраятся по медленной лестнице до 41 часа. refund.rejected и refund.failed — по обычной: денег они не двигали.
Два разных возврата — не перепутай
| Событие | Когда | Кто инициировал |
|---|---|---|
refund.completed |
Исполнена заявка — твоя или наша | Ты или X-Hub |
payment.status_changed со status: refunded |
Банк-эквайер сам отозвал платёж, заявки не было | Банк, без твоего участия |
Второе может прийти через минуты, часы, дни или месяцы после paid / settled — см. возврат после успешной оплаты. Обработчик обязан переживать оба, и оба означают одно: услугу надо отозвать или выставить долг.
Правила сумм
- База возврата — фактически оплаченная сумма. Если клиент заплатил не столько, сколько ожидалось (
amount_mismatch, разобранный вpaid), считается от фактической. - Вернуть можно сразу после оплаты — и полностью, и частично. Частичный возврат до ежедневного расчёта оставляет платёж в
paid, и расчёт начисляет тебе USDT только с остатка: 1000 ₽, возвращено 300 ₽ → начисление с 700 ₽. - Исключение: по части способов оплаты начисление идёт по фактическому поступлению, а не по курсу. Там до расчёта возможен только полный возврат; частичный —
400 PARTIAL_BEFORE_SETTLEMENT, после расчёта (settled) он доступен. - Частичные возвраты доступны: по одному платежу можно подать несколько заявок подряд, пока их сумма укладывается в остаток. Если частичные возвраты временно недоступны, заявка с
amount_rubвернёт400 PARTIAL_DISABLED, а без суммы по-прежнему возвращается весь остаток. - Нельзя вернуть больше остатка: уже созданные и исполненные заявки занимают часть суммы платежа.
- Лимит: до 10 заявок на один платёж. Число одновременно открытых заявок не ограничено.
Сколько списывается с баланса
Комиссия X-Hub берётся за успешный платёж и на возврате не пересматривается: возврат её не отменяет и не уменьшает. Отдельного «сбора за операцию возврата» нет.
- возврат до ежедневного расчёта: начисления ещё не было, с тебя удерживается только комиссия за платёж; при частичном возврате расчёт начислит USDT с остатка;
- возврат после расчёта: начисленное по платежу возвращается, плюс та же комиссия за платёж;
- если выплата за этот платёж ещё не ушла — она пересчитывается автоматически; если USDT уже у тебя — сумма удержится из следующей выплаты.
Если USDT на балансе не хватает на откат начисления, заявка всё равно создаётся (201), но исполняется после подтверждения с нашей стороны — по времени это дольше обычного. Отдельного кода ошибки на это нет: в ответе такая заявка ничем не отличается, и о задержке узнаёшь по тому, что completed не приходит.
Комиссия считается от полной суммы платежа и при частичном возврате не уменьшается. Ставка — terms.markup_percent в GET /cabinet/me.
Обработчик
Минимальная реализация, покрывающая оба пути возврата — заявочный (четыре исхода) и отзыв банком. Каждая ветка должна быть идемпотентна: событие может прийти повторно.
app.post('/webhooks/x-hub', (req, res) => {
// подпись проверена раньше — см. раздел «Верификация подписи»
const e = req.body;
switch (e.event) {
case 'refund.completed':
// заявочный возврат исполнен: деньги ушли клиенту.
// ВАЖНО: payment_id здесь без префикса pay_
revokeAccess(e.payment_id, { refundId: e.refund_id, amount: e.amount_rub });
break;
case 'refund.rejected':
// заявка отклонена — деньги НЕ двигались, услугу трогать не нужно
markRefundRejected(e.refund_id);
break;
case 'refund.failed':
// мы попытались, платёжная сеть отказала: деньги НЕ ушли, клиент ждёт.
// Услугу не трогаем; остаток платежа сорвавшаяся попытка не занимает,
// так что заявку можно создать заново — НОВЫМ Idempotency-Key.
alertOps('Refund failed: ' + e.refund_id);
break;
case 'refund.reversed':
// исполненный возврат откачен: платёж снова живой, деньги снова твои.
// Если по refund.completed доступ отзывали — вернуть его обратно.
restoreAccess(e.payment_id, { refundId: e.refund_id });
break;
case 'payment.status_changed':
if (e.status === 'refunded') {
// банк отозвал платёж сам, заявки не было
revokeAccess(e.payment_id.replace(/^pay_/, ''), { amount: e.amount_rub });
alertOps('Refund-after-paid: ' + e.payment_id);
} else if (e.refund_pending) {
// возврат запрошен, но ещё не исполнен — статус пока прежний
markRefundPending(e.payment_id);
}
break;
}
res.sendStatus(200);
});
@app.post("/webhooks/x-hub")
def x_hub_webhook():
# подпись проверена раньше — см. раздел «Верификация подписи»
e = request.get_json()
event = e.get("event")
if event == "refund.completed":
# заявочный возврат исполнен; payment_id здесь без префикса pay_
revoke_access(e["payment_id"], refund_id=e["refund_id"], amount=e["amount_rub"])
elif event == "refund.rejected":
# деньги не двигались — услугу не трогаем
mark_refund_rejected(e["refund_id"])
elif event == "refund.failed":
# платёжная сеть отказала: деньги НЕ ушли, клиент ждёт. Повторная заявка
# возможна — с НОВЫМ Idempotency-Key
alert_ops(f"Refund failed: {e['refund_id']}")
elif event == "refund.reversed":
# возврат откачен — платёж снова живой, доступ вернуть обратно
restore_access(e["payment_id"], refund_id=e["refund_id"])
elif event == "payment.status_changed":
if e.get("status") == "refunded":
# банк отозвал платёж сам
revoke_access(e["payment_id"].removeprefix("pay_"), amount=e["amount_rub"])
alert_ops(f"Refund-after-paid: {e['payment_id']}")
elif e.get("refund_pending"):
mark_refund_pending(e["payment_id"])
return "", 200
Возврат приходит по услуге, которая уже оказана
Это самый рискованный сценарий интеграции: клиент получил товар на paid, а деньги вернулись ему позже. Ни один флаг не предупредит заранее — заявку может создать поддержка по обращению клиента. Обработчик refund.completed и refunded должен уметь отзывать доступ или выставлять долг, иначе возврат оплачиваешь ты.