Skip to Content
概念退款

退款

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 呈現
ACTIVEToken 有效,now < expires_at退款表單(refund_to_addressreasonamount、選填備註 → metadata.note)
SUBMITTED買家完成表單;Refund 列已存在狀態卡,鏡像 /r/:linkToken
EXPIRED_UNUSEDTTL 在買家送出前過期提示「此連結已過期。請申請新的連結」
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 按鈕 取代表單。點擊後:

  1. POST 到 /pub/v1/refund-requests/:token/request-renewal(無憑證 — token 本身就是 bearer-of-truth)
  2. 選擇性擷取買家想留給商家的自由文字備註(customer_note)
  3. 把 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.createdToken 被鑄造 — data.sourceb2b / 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.*

下一步