Skip to Content
概念退款

退款

Refund 是一等实体,不是 Order 上的一个 flag。你可以发起部分退款、 对同一个 Order 多次退款,或在同一个流程内退款 + 再次扣款。

退款记录可以由两条路径产生:

流程谁来填表鉴权落点
商户发起你的仪表板 / 你的后端HMAC (sk_…)立即落到 APPROVED
客户发起买家,在我们托管页一次性 token(无凭据)PENDING — 由你批准,或在你的配置自动批准时直接通过

客户发起的流程使用一个短期的退款申请 token。你铸造 token(B2B 或 仪表板),按你喜欢的方式把 URL 交给买家,买家在 checkout.infraio.xyz/refund-request/:token 上完成退款细节。买家从不 触达你的 API,也从不看到你的商户密钥。

退款生命周期

状态含义
PENDING退款已记录,等待审批。客户发起的退款总是从这里起步。
APPROVED已放行执行。商户发起的退款直接跳到这里。
REJECTED退款被拒。订单状态不变。
EXECUTED链上转账已确认。订单转为 PARTIALLY_REFUNDED / REFUNDED

商户发起

你决定退款(例如买家在聊天里投诉)。调用商户发起端点 — 它跳过审批, 直接落到 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 合约;通常是原 token }

退款请求上没有 currency 字段 — 退款总是继承订单的显示币种 (目前是 USD)。(refund_to_address, refund_network, refund_token_address) 三元组是链上目的地;payment-service 用它驱动加密 saga。在法币路径中 它们被忽略(由 provider 自动路由)。

订单在你执行链上转账之前(见 执行加密退款)保留其现有状态。


客户发起 — 退款申请 token

买家在我们托管页填退款表单,不在你那里。你唯一要做的就是铸造 token 并把 URL 交付出去。

Token 生命周期

状态含义客户访问 URL 时显示
ACTIVEToken 有效,now < expires_at退款表单(refund_to_addressreasonamount、可选备注 → metadata.note)
SUBMITTED买家已提交表单;存在一条 Refund状态卡片,镜像 /r/:linkToken
EXPIRED_UNUSEDTTL 在买家提交前已过提示:“此链接已过期。申请一个新的”
RENEWAL_REQUESTED买家申请了新链接等待提示:“已通知你的商户”
RENEWED商户批准续期并铸造了替代 token”此链接已被替换 — 请检查邮件获取新链接”(新 token 不会在此显示,以防止链接转发攻击)
CANCELED商户从仪表板吊销了 token纯文本”此退款申请已取消”

Token 一次性。一旦 SUBMITTED,URL 仍然有效,买家可继续查看状态, 但不能再用来提交。要对同一订单发起第二笔退款,请铸造新 token。

TTL 默认值

铸造来源默认 TTL原因
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC)30 分钟程序化 — 假设立即递交给买家。
POST /payment/v1/merchants/{merchant_id}/refund-requests(仪表板 JWT)24 小时手动 — 商户把 URL 粘到邮件 / 短信里。

两条端点都接受 body 中的 ttl_seconds 字段以覆盖默认值。目前没有 服务端强制的硬性 min/max 边界 — 常见取值在 1 分钟到 7 天之间。 请保持在这个范围内,以免给买家带来意外或让已废弃的 token 长期占用容量。

通过 B2B API 铸造

适用于希望在支持对话或订单取消流程结束后,程序化生成退款链接的后端。

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" }, // 可选 — Stripe 风格的键值 "hide_summary": false, // 可选 — 托管表单的 UI 标志 "hide_header": false }

B2B 请求签名是原始小写 hex,没有 sha256= 前缀 — 那个前缀 只出现在入站的 webhook 签名(Infraio → 你的服务器)上。出站的 B2B 签名串是 METHOD\nPATH\nTIMESTAMP\nBODY;规范算法见 身份验证

金额铸造 body 中,而且必填。它锁定买家在表单上可提交的 上限 — 他们可以提交更少,但绝不能更多。(部分退款:用部分金额铸造 token;全额退款:用订单总额铸造。)

为兼容老客户端,旧的 { "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" }

向你注册的 Webhook 端点发出 refund_request.created(便于记录 / 审计 当前对某订单活跃的是哪个 token)。

通过仪表板铸造

商户仪表板  上的 Issue Refund 弹窗 提供一个切换:Execute now vs Send link to customer。后者会 在后台调用 POST /payment/v1/merchants/{merchant_id}/refund-requests (JWT 鉴权,body 形态与上面的 B2B 相同),并把带复制按钮和二维码的 URL 显示给你。把它粘到合适的渠道 — 邮件、支持聊天、短信。

通过 JavaScript SDK — openRefundRequest

如果你的技术栈里已经有 @lartech/infraio-checkout-js,且希望买家在 你自己的页面流程内完成退款(而非外部 URL),把 B2B 铸造与 sdk.openRefundRequest() 配对使用:

// 服务端:铸造 token 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 审批 / 驳回的引用。 window.location.href = `/r/${linkToken}`; }, onCancel: () => { /* 买家关闭了弹窗 */ }, onError: (err) => { /* 见 SDK 参考 */ }, });

完整选项见 SDK 参考 → sdk.openRefundRequest()

客户续期 — 由买家驱动重发

如果买家在 token 过期后打开 URL,页面会提供一个申请新链接按钮代替表单。 点击后:

  1. POST 到 /pub/v1/refund-requests/:token/request-renewal (无凭据 — token 本身就是真相载体)
  2. 可选捕获买家留给商户的自由文本备注(customer_note)
  3. 把 token 移到 RENEWAL_REQUESTED,并向你的 Webhook 发出 refund_request.renewal_requested

你的仪表板会在续期申请组件上展示一个角标。一键批准后,系统会铸造一个 新的 ACTIVE token、发出 refund_request.renewed,并允许你复制新 URL 再次发送。旧 URL 仍可访问,但渲染”已替换 — 请检查邮件”,这样旧 URL 的转发副本就无法被用来钓出新的。


执行加密退款

API 记录意图 — 它移动资金。从商户钱包签名并广播链上 转账,然后把 tx hash 标记回退款记录:

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

三个 body 字段都必填:同一个 tx hash 可在不同链上存在,而且你可以 用与原支付不同的稳定币退款。

当链上观察者看到该 tx 达到配置的确认数(见 链与资产)时,退款翻转为 EXECUTED, 订单的累计退款金额会更新。

我们刻意不托管商户资金,这意味着我们无法代你执行退款。请把链上 发送做进你自己的管理工具 — 从多签或热钱包发起 eth_sendRawTransaction,并以把 tx hash 提交到退款 API 作为流程 的结尾。


Webhook 事件

退款子系统发出两类事件:

Token 生命周期 (refund_request.*)

事件触发条件
refund_request.created一个 token 被铸造 — data.sourceb2b / dashboard / renewal
refund_request.renewal_requested买家在 token 过期后点击了”申请新链接”。请订阅 — 这是商户行动的信号。
refund_request.renewed你批准了续期,新 token 取代旧 token。data.old_token / data.new_token 构成审计链。
refund_request.canceled你从仪表板把 token 翻为 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.*

下一步