Skip to Content
КонцепцииВозвраты

Возвраты

Refund — сущность первого класса, а не флаг на Order. Вы можете делать частичные возвраты, несколько возвратов против одного и того же Order или возврат + повторное списание в одном потоке.

Запись возврата может появиться двумя способами:

ПотокКто заполняет формуАутентификацияПопадает в
Инициированный мерчантомВаша панель / ваш backendHMAC (sk_…)APPROVED немедленно
Инициированный клиентомПокупатель, на нашей размещённой страницеОдноразовый токен (без credential’ов)PENDING — вы одобряете, или замыкается короче, если ваша конфигурация авто-одобряет

Поток, инициированный клиентом, использует короткоживущий refund-request token. Вы выпускаете токен (B2B или из панели), передаёте URL покупателю любым удобным способом, и покупатель заполняет детали возврата на checkout.infraio.xyz/refund-request/:token. Покупатель никогда не касается вашего API и никогда не видит ваш мерчантский ключ.

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

СостояниеЗначит
PENDINGВозврат записан, ожидает одобрения. Возвраты, инициированные клиентом, всегда начинаются здесь.
APPROVEDДопущен к исполнению. Возвраты, инициированные мерчантом, прыгают прямо сюда.
REJECTEDВозврат отклонён. Статус заказа не меняется.
EXECUTEDOn-chain перевод подтверждён. Order переходит в PARTIALLY_REFUNDED / REFUNDED.

Инициированный мерчантом

Вы решаете сделать возврат (например, покупатель жаловался в чате). Вызовите endpoint, инициированный мерчантом — он пропускает review и сразу попадает в APPROVED.

POST /b2b/v1/merchants/{merchant_id}/refunds { "order_id": "ord_01J5K…", "amount": "49.00", // частичный или полный, в валюте отображения заказа "reason": "customer complaint #4521", "refund_to_address": "0xBUYER…", // обязательно для крипто-рейлов "refund_network": "polygon", // slug сети; см. Концепции → Сети "refund_token_address":"0xUSDC_CONTRACT" // ERC-20 контракт, в котором возвращаем; обычно оригинальный токен }

Поля currency в запросе возврата нет — возвраты всегда наследуют валюту отображения заказа (USD сегодня). Тройка (refund_to_address, refund_network, refund_token_address) — это on-chain назначение; payment-service использует её для драйва крипто-saga. На фиатных рейлах она игнорируется (авто-маршрутизация провайдером).

Заказ сохраняет свой текущий статус, пока вы не исполните on-chain перевод (см. Исполнение крипто-возврата).


Инициированный клиентом — refund-request токены

Покупатель заполняет форму возврата на нашей размещённой странице, не на вашей. Ваша единственная задача — выпустить токен и доставить URL.

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

СостояниеЗначитURL клиента отрисовывает
ACTIVEТокен живой, now < expires_atФорму возврата (refund_to_address, reason, amount, необязательная заметка → metadata.note)
SUBMITTEDПокупатель заполнил форму; строка Refund существуетКарточку статуса, отзеркаливающую /r/:linkToken
EXPIRED_UNUSEDTTL истёк до отправки покупателемПодсказка: «Срок действия ссылки истёк. Запросите новую»
RENEWAL_REQUESTEDПокупатель запросил свежую ссылкуУведомление об ожидании: «Ваш мерчант уведомлён»
RENEWEDМерчант одобрил обновление и выпустил замену«Эта ссылка заменена — проверьте email на наличие новой ссылки» (новый токен не раскрывается здесь, чтобы пресечь атаки с пересылкой ссылки)
CANCELEDМерчант отозвал токен из панелиПростое «Этот запрос на возврат отменён»

Токены одноразовые. После SUBMITTED URL остаётся валидным, чтобы покупатель мог проверить статус, но повторно отправить нельзя. Чтобы выпустить второй возврат против того же заказа, сгенерируйте новый токен.

TTL по умолчанию

Источник выпускаTTL по умолчаниюПочему
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC)30 минутПрограммно — предполагается, что URL сразу передаётся покупателю.
POST /payment/v1/merchants/{merchant_id}/refund-requests (JWT панели)24 часаВручную — мерчант вставляет URL в email / SMS.

Оба endpoint’а принимают поле тела ttl_seconds, если хотите переопределить. На сервере жёсткого min/max ограничения сегодня нет — обычные значения от 1 минуты до 7 дней. Оставайтесь в этом диапазоне, чтобы не удивлять покупателей и не держать ёмкость на отменённых токенах.

Выпуск через B2B API

Для backend’ов, которые хотят программно генерировать ссылку возврата сразу после разговора в поддержке, потока отмены заказа и т. д.

POST /b2b/v1/merchants/{merchant_id}/refund-requests Content-Type: application/json X-Client-ID: pk_live_… X-Timestamp: 1729536000 X-Signature: 4f2a1b9c8d3e2f1a0b9c8d7e6f5a4b3c… { "ref_type": "order_id", // обязательно: order_id | order_number | session_id | session_key "ref_value": "ord_01J5K…", // обязательно: соответствует ref_type "amount": "49.00", // обязательно — фиксирует максимум, который покупатель может отправить "ttl_seconds": 1800, // опционально — по умолчанию 1800 (30 мин) "metadata": { "support_ticket": "4521" }, // опционально — key/value в стиле Stripe "hide_summary": false, // опциональные UI-флаги для размещённой формы "hide_header": false }

Подпись запроса B2B — это сырой hex в нижнем регистре без префикса sha256= — этот префикс появляется только на входящих подписях webhook (Infraio → ваш сервер). Исходящая строка подписи B2B — METHOD\nPATH\nTIMESTAMP\nBODY; см. Аутентификация для канонического алгоритма.

Сумма входит в тело выпуска и обязательна. Она фиксирует потолок, который покупатель может отправить через форму — он может отправить меньше, но никогда больше. (Для частичных возвратов выпускайте токен с частичной суммой; для полных возвратов выпускайте с общей суммой заказа.)

Legacy-форма { "order_id": "..." } всё ещё принимается для обратной совместимости — внутри она мапится в (ref_type=order_id, ref_value=...) — но новые интеграции должны использовать явную пару ref_type + ref_value.

Ответ:

{ "token": "rfqt_01J7P3Q9R…", "refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…", "expires_at": "2026-05-28T10:32:00Z" }

Отправляет refund_request.created на ваши webhook-endpoint’ы (чтобы вы могли логировать / аудировать, какой токен сейчас активен для заказа).

Выпуск через панель

Модал Issue Refund в панели мерчанта  показывает переключатель: Execute now vs Send link to customer. Выбор второго варианта вызывает POST /payment/v1/merchants/{merchant_id}/refund-requests за кулисами (JWT-аутентификация, та же форма тела, что и B2B выше), а затем показывает URL с кнопкой копирования и QR-кодом. Вставляйте его в любой подходящий канал — email, чат поддержки, SMS.

Через JavaScript SDK — openRefundRequest

Если у вас уже есть @lartech/infraio-checkout-js в стеке и вы хотите, чтобы покупатель завершил возврат внутри вашего собственного потока страницы (а не через внешний URL), сочетайте B2B-выпуск с sdk.openRefundRequest():

// На сервере: выпустить токен const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json()); // На клиенте: открыть размещённую форму const sdk = await loadInfraIo("pk_live_yourkeyhere"); const close = sdk.openRefundRequest({ token, mode: "popup", // или "redirect" | "embed" onSuccess: ({ linkToken, refundId }) => { // linkToken → /r/:linkToken страница статуса покупателя. // refundId → ссылка B2B API для approve / reject. window.location.href = `/r/${linkToken}`; }, onCancel: () => { /* покупатель закрыл popup */ }, onError: (err) => { /* см. справочник SDK */ }, });

См. Справочник SDK → sdk.openRefundRequest() для полной таблицы опций.

Обновление по запросу клиента — переоформление покупателем

Если покупатель открывает URL после истечения токена, страница вместо формы показывает кнопку Запросить новую ссылку. Клик по ней:

  1. Делает POST на /pub/v1/refund-requests/:token/request-renewal (без credential’ов — токен сам по себе bearer-of-truth)
  2. Опционально захватывает свободный текст (customer_note), который покупатель может оставить мерчанту
  3. Переводит токен в RENEWAL_REQUESTED и отправляет refund_request.renewal_requested на ваш webhook

Ваша панель показывает бейдж на виджете запросов обновлений. Одобрите его (один клик), и система выпустит новый ACTIVE токен, отправит refund_request.renewed и позволит вам скопировать новый URL, чтобы отправить снова. Старый URL остаётся доступным, но показывает «Заменён — проверьте email», чтобы пересланная копия старого URL не могла извлечь новый.


Исполнение крипто-возврата

API записывает намерение — но не двигает средства. Вы подписываете и транслируете on-chain перевод с вашего мерчантского кошелька, затем проставляете tx hash на записи возврата:

POST /b2b/v1/refunds/:refund_id/submit-tx Content-Type: application/json { "tx_hash": "0xabcd…", "network": "ethereum", "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }

Все три поля тела обязательны: один и тот же tx hash может существовать на разных сетях, и вы можете вернуть в другом стейблкоине, чем тот, в котором был принят оригинальный платёж.

Когда chain watcher увидит, что эта tx набрала настроенное число подтверждений (см. Сети и активы), возврат перейдёт в EXECUTED, а общая возвращённая сумма Order будет обновлена.

Мы сознательно не храним средства мерчанта, что означает, что мы не можем исполнять возвраты от вашего имени. Встройте on-chain отправку в вашу админ-инструментацию — eth_sendRawTransaction из multisig или горячего кошелька, с workflow, который заканчивается отправкой tx hash в API возврата.


События webhook

Подсистема возвратов отправляет два семейства событий:

Жизненный цикл токена (refund_request.*)

СобытиеСрабатывает когда
refund_request.createdТокен был выпущен — data.source равно b2b / dashboard / renewal
refund_request.renewal_requestedПокупатель нажал «Запросить новую ссылку» после истечения токена. Подпишитесь на это — это сигнал мерчанту действовать.
refund_request.renewedВы одобрили обновление, и новый токен заменил старый. data.old_token / data.new_token формируют аудиторскую цепочку.
refund_request.canceledВы перевели токен в CANCELED из панели. Идемпотентно — отправляется только при первом переходе. data.reason — опциональная заметка мерчанта.

Жизненный цикл возврата (payment.refund.*)

СобытиеСрабатывает когда
payment.refund.requestedНовая строка Refund существует — из любого источника (отправка формы, API, инициированный мерчантом, панель).
payment.refund.approvedВозврат одобрен — либо авто-одобрен (инициированный мерчантом), либо после того, как вы вызвали /approve на ожидающем.
payment.refund.rejectedВы вызвали /reject на ожидающем возврате.
payment.refund.executedСредства переведены (ваш крипто tx hash набрал требуемые подтверждения).

payment.failed не срабатывает для возврата — у возвратов своя серия событий под префиксом payment.refund.*.

Что дальше