Возвраты
Refund — сущность первого класса, а не флаг на Order. Вы можете делать частичные возвраты, несколько возвратов против одного и того же Order или возврат + повторное списание в одном потоке.
Запись возврата может появиться двумя способами:
| Поток | Кто заполняет форму | Аутентификация | Попадает в |
|---|---|---|---|
| Инициированный мерчантом | Ваша панель / ваш backend | HMAC (sk_…) | APPROVED немедленно |
| Инициированный клиентом | Покупатель, на нашей размещённой странице | Одноразовый токен (без credential’ов) | PENDING — вы одобряете, или замыкается короче, если ваша конфигурация авто-одобряет |
Поток, инициированный клиентом, использует короткоживущий
refund-request token. Вы выпускаете токен (B2B или из панели),
передаёте URL покупателю любым удобным способом, и покупатель заполняет
детали возврата на checkout.infraio.xyz/refund-request/:token.
Покупатель никогда не касается вашего API и никогда не видит ваш
мерчантский ключ.
Жизненный цикл возврата
| Состояние | Значит |
|---|---|
PENDING | Возврат записан, ожидает одобрения. Возвраты, инициированные клиентом, всегда начинаются здесь. |
APPROVED | Допущен к исполнению. Возвраты, инициированные мерчантом, прыгают прямо сюда. |
REJECTED | Возврат отклонён. Статус заказа не меняется. |
EXECUTED | On-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_UNUSED | TTL истёк до отправки покупателем | Подсказка: «Срок действия ссылки истёк. Запросите новую» |
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 после истечения токена, страница вместо формы показывает кнопку Запросить новую ссылку. Клик по ней:
- Делает POST на
/pub/v1/refund-requests/:token/request-renewal(без credential’ов — токен сам по себе bearer-of-truth) - Опционально захватывает свободный текст (
customer_note), который покупатель может оставить мерчанту - Переводит токен в
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.*.
Что дальше
- Справочник SDK →
sdk.openRefundRequest()— открыть размещённую форму возврата как popup / redirect / embed. - Справочник API → Возвраты — каталог endpoint’ов (выпуск, отправка, обновление, статус).
- Концепции → Заказы — как состояние Refund возвращается обратно в жизненный цикл Order.
- Webhooks → Обзор — полный каталог событий.