退款
Refund 是一級實體,而非 Order 上的旗標。你可以發起部分退款、 針對同一個訂單發起多筆退款,或在同一流程中退款 + 重新收款。
退款紀錄有兩種產生方式:
| 流程 | 由誰填表單 | 驗證 | 落點 |
|---|---|---|---|
| 商家發起 | 你的儀表板 / 你的後端 | HMAC(sk_…) | 立即落在 APPROVED |
| 客戶發起 | 買家,在我們的託管頁 | 一次性 token(無憑證) | PENDING — 你核准,或若你的設定為自動核准則直接放行 |
客戶發起的流程使用短效的退款申請 token。你鑄造 token(B2B 或
儀表板),用任何你喜歡的方式把 URL 交給買家,買家就在
checkout.infraio.xyz/refund-request/:token 完成退款細節。買家
永遠不會接觸你的 API,也看不到你的商家金鑰。
退款生命週期
| 狀態 | 意義 |
|---|---|
PENDING | 退款已記錄,等待核准。客戶發起的退款一律從這裡開始。 |
APPROVED | 已可執行。商家發起的退款直接跳到這裡。 |
REJECTED | 退款被駁回。訂單狀態不變。 |
EXECUTED | 鏈上轉帳已確認。訂單轉為 PARTIALLY_REFUNDED / REFUNDED。 |
商家發起
你決定退款(例如買家在 chat 上抱怨)。呼叫商家發起的 endpoint —
它會跳過審查並立即落在 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 呈現 |
|---|---|---|
ACTIVE | Token 有效,now < expires_at | 退款表單(refund_to_address、reason、amount、選填備註 → metadata.note) |
SUBMITTED | 買家完成表單;Refund 列已存在 | 狀態卡,鏡像 /r/:linkToken |
EXPIRED_UNUSED | TTL 在買家送出前過期 | 提示「此連結已過期。請申請新的連結」 |
RENEWAL_REQUESTED | 買家申請了新連結 | 等待通知:「已通知你的商家」 |
RENEWED | 商家核准續期並鑄造替代 token | 「此連結已被替換 — 請查看 email 中的新連結」(新 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 貼到 email / SMS。 |
兩個 endpoint 都接受 body 中的 ttl_seconds 欄位以覆寫預設。目前
伺服端不強制任何最小或最大上下限 — 常見值為 1 分鐘到 7 天。
請維持在這個範圍內,避免讓買家覺得意外,或在已取消的 token 上
保留容量。
透過 B2B API 鑄造
對於想在 support 對話、訂單取消流程等之後以程式產生退款連結的後端。
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 風格 key/value
"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 modal
提供切換:Execute now vs Send link to customer。選擇後者
會在背後呼叫 POST /payment/v1/merchants/{merchant_id}/refund-requests
(JWT 驗證,body 結構與上面的 B2B 相同),然後顯示 URL 加上複製
按鈕與 QR code。把它貼到任何適合的通道 — email、support chat、SMS。
透過 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());
// Client 端:開啟託管表單
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: () => { /* 買家關閉了 popup */ },
onError: (err) => { /* 詳見 SDK 參考 */ },
});完整的選項表請見 SDK 參考 → sdk.openRefundRequest()。
客戶續期 — 買家驅動的重發
若買家在 token 過期後打開 URL,頁面會提供 Request new link 按鈕 取代表單。點擊後:
- POST 到
/pub/v1/refund-requests/:token/request-renewal(無憑證 — token 本身就是 bearer-of-truth) - 選擇性擷取買家想留給商家的自由文字備註(
customer_note) - 把 token 移到
RENEWAL_REQUESTED並對你的 webhook 發出refund_request.renewal_requested
你的儀表板會在 renewal-requests widget 上顯示徽章。一鍵核准後,
系統會鑄造新的 ACTIVE token、發出 refund_request.renewed,
讓你複製新 URL 再寄一次。舊 URL 仍可存取,但會顯示
「Replaced — check your email」,讓被轉發的舊 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 可能存在於不同鏈上,而且 你可能以與原始付款不同的穩定幣退款。
當 chain watcher 看到那筆 tx 達到設定的確認數(見支援的鏈與資產)
時,退款會翻為 EXECUTED,訂單的退款總額也會更新。
我們刻意不託管商家資金,因此無法代你執行退款。請把鏈上送出整合到
你的 admin 工具中 — 從 multisig 或 hot wallet 做
eth_sendRawTransaction,並讓流程結束於把 tx hash 提交到退款 API。
Webhook 事件
退款子系統會發出兩個事件家族:
Token 生命週期(refund_request.*)
| 事件 | 觸發時機 |
|---|---|
refund_request.created | Token 被鑄造 — data.source 為 b2b / dashboard / renewal |
refund_request.renewal_requested | 買家在 token 過期後點「Request new link」。請訂閱此事件 — 它是商家行動的觸發點。 |
refund_request.renewed | 你核准續期,新 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.*。
下一步
- SDK 參考 →
sdk.openRefundRequest()— 以 popup / redirect / embed 開啟託管退款表單。 - API 參考 → Refunds — endpoint 目錄(鑄造、提交、續期、狀態)。
- 概念 → 訂單 — Refund 狀態如何串回 Order 生命週期。
- Webhooks → 總覽 — 完整事件目錄。