Skip to Content
Khái niệmHoàn tiền

Hoàn tiền

Refund là một entity hạng nhất, không phải một cờ trên Order. Bạn có thể phát hành refund từng phần, nhiều refund cùng cho một Order, hoặc refund + tính phí lại trong cùng một luồng.

Có hai cách bản ghi refund có thể được tạo ra:

LuồngAi điền formAuthTrạng thái khi tạo
Merchant khởi tạoDashboard / backend của bạnHMAC (sk_…)APPROVED ngay lập tức
Khách hàng khởi tạoNgười mua, trên trang do hệ thống hostToken dùng một lần (không cần creds)PENDING — bạn approve, hoặc tự động ngắn mạch nếu cấu hình auto-approve

Luồng do khách hàng khởi tạo dùng một token refund-request ngắn hạn. Bạn phát hành token (qua B2B hoặc dashboard), giao URL cho người mua theo cách bạn muốn, và người mua hoàn tất chi tiết refund trên checkout.infraio.xyz/refund-request/:token. Người mua không bao giờ chạm vào API của bạn và không bao giờ thấy merchant key của bạn.

Vòng đời refund

Trạng tháiÝ nghĩa
PENDINGRefund đã ghi nhận, đang chờ approve. Refund do khách hàng khởi tạo luôn bắt đầu ở đây.
APPROVEDĐã được duyệt để thực thi. Refund do merchant khởi tạo nhảy thẳng vào đây.
REJECTEDRefund bị từ chối. Trạng thái Order không đổi.
EXECUTEDGiao dịch chuyển on-chain đã được xác nhận. Order chuyển sang PARTIALLY_REFUNDED / REFUNDED.

Merchant khởi tạo

Bạn quyết định refund (ví dụ người mua khiếu nại qua chat). Gọi endpoint do merchant khởi tạo — nó bỏ qua review và lập tức rơi vào APPROVED.

POST /b2b/v1/merchants/{merchant_id}/refunds { "order_id": "ord_01J5K…", "amount": "49.00", // partial hoặc full, theo display currency của order "reason": "customer complaint #4521", "refund_to_address": "0xBUYER…", // bắt buộc cho rail crypto "refund_network": "polygon", // network slug; xem Khái niệm → Chains "refund_token_address":"0xUSDC_CONTRACT" // contract ERC-20 được trả lại; thường là token gốc }

Không có trường currency trong refund request — refund luôn kế thừa display currency của order (USD hiện tại). Bộ ba (refund_to_address, refund_network, refund_token_address) là đích on-chain; payment-service dùng chúng để điều khiển saga crypto. Chúng bị bỏ qua với rail fiat (provider tự định tuyến).

Order giữ nguyên trạng thái hiện tại cho đến khi bạn thực thi chuyển on-chain (xem Thực thi refund crypto).


Khách hàng khởi tạo — token refund-request

Người mua điền form refund trên trang do hệ thống host, không phải trang của bạn. Việc duy nhất của bạn là phát hành token và chuyển URL.

Vòng đời token

Trạng tháiÝ nghĩaURL khách hàng hiển thị
ACTIVEToken đang chạy, now < expires_atForm refund (refund_to_address, reason, amount, ghi chú tùy chọn → metadata.note)
SUBMITTEDNgười mua đã hoàn tất form; bản ghi Refund đã tồn tạiThẻ trạng thái mirror /r/:linkToken
EXPIRED_UNUSEDTTL đã hết trước khi người mua submitThông báo: “This link has expired. Request a new one”
RENEWAL_REQUESTEDNgười mua đã yêu cầu link mớiThông báo chờ: “Your merchant has been notified”
RENEWEDMerchant đã approve renewal và phát hành thay thế”This link has been replaced — check your email for the new link” (token mới không bị tiết lộ ở đây để vô hiệu hóa kiểu tấn công forward link)
CANCELEDMerchant đã thu hồi token từ dashboard”This refund request was canceled” đơn giản

Token là single-use. Khi đã SUBMITTED, URL vẫn còn hiệu lực để người mua kiểm tra trạng thái nhưng không thể submit lại. Để phát hành refund thứ hai cho cùng order, hãy phát hành token mới.

TTL mặc định

Nguồn phát hànhTTL mặc địnhVì sao
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC)30 phútProgrammatic — giả định được giao cho người mua ngay lập tức.
POST /payment/v1/merchants/{merchant_id}/refund-requests (dashboard JWT)24 giờThủ công — merchant paste URL vào email / SMS.

Cả hai endpoint chấp nhận trường body ttl_seconds nếu bạn muốn override. Hiện không có ngưỡng min/max cứng phía server — giá trị thường gặp là 1 phút đến 7 ngày. Giữ trong khoảng đó để tránh làm người mua bất ngờ hoặc giữ chỗ trên các token đã hủy.

Phát hành qua B2B API

Dành cho backend muốn tự động tạo link refund ngay sau một cuộc hội thoại support, một luồng hủy order, v.v.

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", // required: order_id | order_number | session_id | session_key "ref_value": "ord_01J5K…", // required: matches ref_type "amount": "49.00", // required — locks the maximum the buyer can submit "ttl_seconds": 1800, // optional — defaults to 1800 (30 min) "metadata": { "support_ticket": "4521" }, // optional — Stripe-style key/value "hide_summary": false, // optional UI flags for the hosted form "hide_header": false }

Chữ ký request B2B là hex lowercase thuần với không có prefix sha256= — prefix đó chỉ xuất hiện trên chữ ký webhook inbound (Infraio → server của bạn). Chuỗi ký B2B outbound là METHOD\nPATH\nTIMESTAMP\nBODY; xem Xác thực để biết thuật toán canonical.

Trường amount nằm trong body phát hành và bắt buộc. Nó khóa trần mà người mua có thể submit trên form — họ có thể submit ít hơn nhưng không bao giờ vượt quá. (Với refund partial, phát hành token với số partial; với refund đầy đủ, phát hành với tổng order.)

Cấu trúc legacy { "order_id": "..." } vẫn được chấp nhận để tương thích ngược — bên trong nó được map thành (ref_type=order_id, ref_value=...) — nhưng các tích hợp mới nên dùng cặp ref_type + ref_value tường minh.

Response:

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

Phát refund_request.created đến các webhook endpoint của bạn (để bạn có thể log / audit token nào hiện đang active cho một order).

Phát hành qua dashboard

Modal Issue Refund trong merchant dashboard  bộc lộ một toggle: Execute now vs Send link to customer. Chọn mục thứ hai sẽ gọi POST /payment/v1/merchants/{merchant_id}/refund-requests ở hậu trường (xác thực JWT, body cùng cấu trúc như B2B ở trên), rồi hiển thị URL cho bạn cùng nút copy và QR code. Paste vào kênh phù hợp — email, support chat, SMS.

Qua JavaScript SDK — openRefundRequest

Nếu bạn đã có @lartech/infraio-checkout-js trong stack và muốn người mua hoàn tất refund bên trong luồng trang của bạn (không qua URL bên ngoài), kết hợp lệnh phát hành B2B với sdk.openRefundRequest():

// Server-side: phát hành token const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json()); // Client-side: mở form do hệ thống host const sdk = await loadInfraIo("pk_live_yourkeyhere"); const close = sdk.openRefundRequest({ token, mode: "popup", // hoặc "redirect" | "embed" onSuccess: ({ linkToken, refundId }) => { // linkToken → trang trạng thái /r/:linkToken cho người mua. // refundId → reference B2B API để approve / reject. window.location.href = `/r/${linkToken}`; }, onCancel: () => { /* người mua đã đóng popup */ }, onError: (err) => { /* xem tham chiếu SDK */ }, });

Xem Tham chiếu SDK → sdk.openRefundRequest() để biết bảng option đầy đủ.

Customer renewal — phát hành lại do người mua chủ động

Nếu người mua mở URL sau khi token đã hết hạn, trang sẽ hiển thị nút Request new link thay cho form. Bấm vào sẽ:

  1. POST tới /pub/v1/refund-requests/:token/request-renewal (không creds — bản thân token là bearer-of-truth)
  2. Tùy chọn ghi nhận một ghi chú tự do (customer_note) mà người mua có thể để lại cho merchant
  3. Chuyển token sang RENEWAL_REQUESTED và phát refund_request.renewal_requested đến webhook của bạn

Dashboard của bạn sẽ hiển thị badge trên widget renewal-requests. Approve nó (một cú click) và hệ thống phát hành một token ACTIVE mới, phát refund_request.renewed, và cho phép bạn copy URL mới để gửi lại. URL cũ vẫn truy cập được nhưng hiển thị “Replaced — check your email” để một bản copy URL cũ bị forward không thể dùng để dò ra URL mới.


Thực thi refund crypto

API ghi nhận intent — nó không di chuyển vốn. Bạn ký và broadcast giao dịch chuyển on-chain từ ví merchant của mình, rồi stamp tx hash ngược trở lại bản ghi refund:

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

Cả ba trường body đều bắt buộc: cùng một tx hash có thể tồn tại trên các chain khác nhau, và bạn có thể refund bằng stablecoin khác với loại mà thanh toán gốc đã capture.

Khi chain watcher thấy tx đó clear đủ số confirmation cấu hình (xem Chains & tài sản), refund chuyển sang EXECUTED và tổng số đã refund của Order được cập nhật.

Chúng tôi chủ ý không giữ custody vốn của merchant, nghĩa là chúng tôi không thể thực thi refund thay bạn. Hãy xây phần gửi on-chain vào tool admin của bạn — eth_sendRawTransaction từ một multisig hoặc ví hot, với workflow kết thúc bằng việc POST tx hash vào refund API.


Sự kiện webhook

Subsystem refund phát hai họ event:

Vòng đời token (refund_request.*)

EventPhát khi
refund_request.createdMột token được phát hành — data.sourceb2b / dashboard / renewal
refund_request.renewal_requestedMột người mua bấm “Request new link” sau khi token của họ hết hạn. Hãy subscribe — đây là tín hiệu để merchant hành động.
refund_request.renewedBạn đã approve một renewal và một token mới thay thế cái cũ. data.old_token / data.new_token tạo thành chuỗi audit.
refund_request.canceledBạn đã chuyển một token sang CANCELED từ dashboard. Idempotent — chỉ transition đầu tiên phát event. data.reason là ghi chú tùy chọn của merchant.

Vòng đời refund (payment.refund.*)

EventPhát khi
payment.refund.requestedMột bản ghi Refund mới tồn tại — từ bất kỳ nguồn nào (form submit, API do merchant khởi tạo, dashboard).
payment.refund.approvedRefund được approve — hoặc auto-approve (do merchant khởi tạo) hoặc sau khi bạn gọi /approve trên một refund pending.
payment.refund.rejectedBạn đã gọi /reject trên một refund pending.
payment.refund.executedVốn đã di chuyển (tx hash crypto của bạn đã đạt confirmation yêu cầu).

payment.failed không được phát cho refund — refund có chuỗi event riêng dưới prefix payment.refund.*.

Tiếp theo