--- title: "Возвраты" url: "https://docs.x-hub.online/payments/refunds/" description: "Заявка на возврат, её исход и что списывается с баланса." relatedDocuments: - "https://docs.x-hub.online/payments/" - "https://docs.x-hub.online/payments/errors/" --- # Возвраты { #refunds } Возврат — это операция над **уже успешным** платежом: деньги, которые клиент заплатил, уходят обратно ему, а соответствующая сумма списывается с твоего баланса. Возврат в X-Hub — **заявка**, а не мгновенная операция. Ты её создаёшь, мы исполняем, исход приходит вебхуком. Синхронного «вернул и получил ответ» не бывает нигде в отрасли, и наш API этого не обещает. !!! info "Ручка возвратов включается по запросу" `/api/v1/refunds` включается вашему аккаунту по обращению в X-Hub. Пока не включена — она отвечает `404`, а возврат создаётся кнопкой в [кабинете](https://app.x-hub.online). В **песочнице** возвраты работают полностью и доходят до `completed` сами — интеграцию проверяй там, не на живых деньгах. ## Что доступно { #refunds-capabilities } | Задача | Как | |---|---| | Начать возврат | `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 { #refund-create } `Idempotency-Key` **обязателен**. Обрыв связи на этом запросе — обычное дело, и без ключа ретрай твоего HTTP-клиента завёл бы вторую заявку: клиент получил бы деньги дважды. С ключом повтор возвращает ту же заявку и `200` вместо `201`. === "cURL" ```bash 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" }' ``` === "Node.js" ```js 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(); ``` === "Python" ```python 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` на повторе того же ключа): ```json { "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 заявок на один платёж | !!! warning "`Idempotency-Key` — это UUID, а не твой номер заказа" Ключ обязан быть UUID (`randomUUID()` / `uuid4()`); `order-42-refund` вернёт `400 VALIDATION_ERROR`. Свою ссылку на заказ клади в `external_id` — она и хранится, и ищется в `GET /refunds`. Ключ живёт **внутри своей среды**: один и тот же UUID в песочнице и в бою — две разные заявки, не склеиваются. ## Отменить заявку — POST /refunds/:id/cancel { #refund-cancel } Пока заявку не взяли в работу, её можно отменить самому — заводить обращение в поддержку из-за опечатки не нужно. ```bash 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 { #refund-list } ```bash 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 { #refund-get } ```bash 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 } С момента создания заявки платёж отдаётся с `"refund_pending": true`. Основной `status` при этом **не меняется** — платёж всё ещё `paid` или `settled`, потому что деньги клиенту ещё не вернулись, а заявка может быть и отклонена. ```json { "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000", "status": "settled", "refund_pending": true, "amount_rub": "1000.00" } ``` Не жди нового значения `status` как признака начавшегося возврата — ориентируйся на этот флаг. Поле присутствует всегда, у обычного платежа оно `false`. ## Исход — четыре события { #refund-outcome } У заявки четыре терминальных исхода, и путать их дорого: | Событие | Что произошло | Что делать | |---|---|---| | `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-vs-refunded } | Событие | Когда | Кто инициировал | |---|---|---| | `refund.completed` | Исполнена **заявка** — твоя или наша | Ты или X-Hub | | `payment.status_changed` со `status: refunded` | Банк-эквайер сам отозвал платёж, заявки не было | Банк, без твоего участия | Второе может прийти через минуты, часы, дни или месяцы после `paid` / `settled` — см. [возврат после успешной оплаты](./index.md#payments). Обработчик обязан переживать оба, и оба означают одно: услугу надо отозвать или выставить долг. ## Правила сумм { #refund-amounts } - **База возврата — фактически оплаченная сумма.** Если клиент заплатил не столько, сколько ожидалось (`amount_mismatch`, разобранный в `paid`), считается от фактической. - **Вернуть можно сразу после оплаты** — и полностью, и частично. Частичный возврат до ежедневного расчёта оставляет платёж в `paid`, и расчёт начисляет тебе USDT только с остатка: 1000 ₽, возвращено 300 ₽ → начисление с 700 ₽. - **Исключение:** по части способов оплаты начисление идёт по фактическому поступлению, а не по курсу. Там до расчёта возможен только полный возврат; частичный — `400 PARTIAL_BEFORE_SETTLEMENT`, после расчёта (`settled`) он доступен. - **Частичные возвраты доступны**: по одному платежу можно подать несколько заявок подряд, пока их сумма укладывается в остаток. Если частичные возвраты временно недоступны, заявка с `amount_rub` вернёт `400 PARTIAL_DISABLED`, а без суммы по-прежнему возвращается весь остаток. - **Нельзя вернуть больше остатка**: уже созданные и исполненные заявки занимают часть суммы платежа. - Лимит: до **10** заявок на один платёж. Число одновременно открытых заявок не ограничено. ## Сколько списывается с баланса { #refund-money } Комиссия X-Hub берётся за **успешный платёж** и на возврате не пересматривается: возврат её не отменяет и не уменьшает. Отдельного «сбора за операцию возврата» нет. - возврат **до** ежедневного расчёта: начисления ещё не было, с тебя удерживается только комиссия за платёж; при частичном возврате расчёт начислит USDT с остатка; - возврат **после** расчёта: начисленное по платежу возвращается, плюс та же комиссия за платёж; - если выплата за этот платёж ещё не ушла — она пересчитывается автоматически; если USDT уже у тебя — сумма удержится из следующей выплаты. Если USDT на балансе не хватает на откат начисления, заявка всё равно создаётся (`201`), но исполняется после подтверждения с нашей стороны — по времени это дольше обычного. Отдельного кода ошибки на это нет: в ответе такая заявка ничем не отличается, и о задержке узнаёшь по тому, что `completed` не приходит. Комиссия считается от полной суммы платежа и при частичном возврате не уменьшается. Ставка — `terms.markup_percent` в `GET /cabinet/me`. ## Обработчик { #refund-handler } Минимальная реализация, покрывающая оба пути возврата — заявочный (четыре исхода) и отзыв банком. Каждая ветка должна быть идемпотентна: событие может прийти повторно. === "Node.js" ```js 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); }); ``` === "Python" ```python @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 ``` !!! danger "Возврат приходит по услуге, которая уже оказана" Это самый рискованный сценарий интеграции: клиент получил товар на `paid`, а деньги вернулись ему позже. Ни один флаг не предупредит заранее — заявку может создать поддержка по обращению клиента. Обработчик `refund.completed` и `refunded` должен уметь **отзывать доступ или выставлять долг**, иначе возврат оплачиваешь ты. ---