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

Возвраты

Возврат — это операция над уже успешным платежом: деньги, которые клиент заплатил, уходят обратно ему, а соответствующая сумма списывается с твоего баланса.

Возврат в 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 должен уметь отзывать доступ или выставлять долг, иначе возврат оплачиваешь ты.