Refunds
A Refund is a first-class entity, not a flag on the Order. You can issue partial refunds, multiple refunds against the same Order, or refund + re-charge in the same flow.
Refunds can also be issued from the merchant app.
Two ways the refund record can come into existence:
| Flow | Who fills the form | Auth | Lands in |
|---|---|---|---|
| Merchant-initiated | Your dashboard / your backend | HMAC (sk_…) | APPROVED immediately |
| Customer-initiated | The buyer, on our hosted page | One-time token (no creds) | PENDING — you approve, or it short-circuits if your config auto-approves |
The customer-initiated flow uses a short-lived refund-request
token. You mint a token (B2B or dashboard), hand the URL to the
buyer however you like, and the buyer completes the refund details
on checkout.infraio.xyz/refund-request/:token. The buyer never
touches your API and never sees your merchant key.
Refund lifecycle
| State | Means |
|---|---|
PENDING | Refund recorded, awaiting approval. Customer-initiated refunds always start here. |
APPROVED | Cleared for execution. Merchant-initiated refunds jump here directly. |
REJECTED | Refund denied. Order status unchanged. |
EXECUTED | On-chain transfer confirmed. Order moves to PARTIALLY_REFUNDED / REFUNDED. |
Merchant-initiated
You decide to refund (e.g., the buyer complained over chat). Call
the merchant-initiated endpoint — it skips review and lands in
APPROVED immediately.
POST /b2b/v1/merchants/{merchant_id}/refunds
{
"order_id": "ord_01J5K…",
"amount": "49.00", // partial or full, in the order's display currency
"reason": "customer complaint #4521",
"refund_to_address": "0xBUYER…", // required for crypto rails
"refund_network": "polygon", // network slug; see Concepts → Chains
"refund_token_address":"0xUSDC_CONTRACT" // ERC-20 contract paid back; usually the original token
}There is no currency field on the refund request — refunds always
inherit the order’s display currency (USD today). The triple
(refund_to_address, refund_network, refund_token_address) is the
on-chain destination. They’re ignored for fiat rails (auto-routed by the
provider).
The order keeps its existing status until you execute the on-chain transfer (see Executing a crypto refund).
Customer-initiated — refund-request tokens
The buyer fills the refund form on our hosted page, not yours. Your only job is to mint a token and deliver the URL.
Token lifecycle
| State | Means | Customer URL renders |
|---|---|---|
ACTIVE | Token is live, now < expires_at | The refund form (refund_to_address, reason, amount, optional note → metadata.note) |
SUBMITTED | Buyer completed the form; a refund record exists | Status card mirroring /r/:linkToken |
EXPIRED_UNUSED | TTL elapsed before the buyer submitted | Prompt: “This link has expired. Request a new one” |
RENEWAL_REQUESTED | Buyer asked for a fresh link | Waiting notice: “Your merchant has been notified” |
RENEWED | Merchant approved the renewal and minted a replacement | “This link has been replaced — check your email for the new link” (the new token is not revealed here, to defeat forwarded-link attacks) |
CANCELED | Merchant revoked the token from the dashboard | Plain “This refund request was canceled” |
Tokens are single-use. Once SUBMITTED, the URL stays valid for the
buyer to check status but can’t be used to submit again. To issue a
second refund against the same order, mint a new token.
TTL defaults
| Mint source | Default TTL | Why |
|---|---|---|
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC) | 30 minutes | Programmatic — assumed to be handed to the buyer immediately. |
| Merchant dashboard | 24 hours | Manual — merchant pastes the URL into an email / SMS. |
You can override the default with the ttl_seconds body field. No
minimum or maximum is enforced; common values range from 1 minute to
7 days.
Mint via B2B API
For backends that want to programmatically generate a refund link right after a support conversation, an order cancellation flow, etc.
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
}The B2B request signature is raw lowercase hex with no
sha256= prefix — that prefix only appears on inbound webhook
signatures (Infraio → your server). The outbound B2B signing string
is METHOD\nPATH\nTIMESTAMP\nBODY; see
Authentication for the canonical
algorithm.
The amount is in the mint body and is required. It locks the ceiling the buyer can submit on the form — they can submit for less but never more. (For partial refunds, mint a token with the partial amount; for full refunds, mint with the order total.)
The older { "order_id": "..." } shape is still accepted and is treated
as ref_type=order_id, but new integrations should use the explicit
ref_type + ref_value pair.
Response:
{
"token": "rfqt_01J7P3Q9R…",
"refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…",
"expires_at": "2026-05-28T10:32:00Z"
}Fires refund_request.created to your webhook endpoints (so you can
log / audit which token is currently active for an order).
Mint via dashboard
The Issue Refund modal in the merchant dashboard exposes a toggle: Execute now vs Send link to customer. Picking the latter creates a refund-request token (same as the B2B call above) and shows you the URL with a copy button and a QR code. Paste it into whichever channel makes sense — email, support chat, SMS.
Via the JavaScript SDK — openRefundRequest
If you already have @lartech/infraio-checkout-js in your stack and
want the buyer to complete the refund inside your own page flow (not
via an external URL), pair the B2B mint with sdk.openRefundRequest():
// Server-side: mint the token
const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json());
// Client-side: open the hosted form
const sdk = await loadInfraIo("pk_live_yourkeyhere");
const close = sdk.openRefundRequest({
token,
mode: "popup", // or "redirect" | "embed"
onSuccess: ({ linkToken, refundId }) => {
// linkToken → /r/:linkToken buyer status page.
// refundId → B2B API reference for approve / reject.
window.location.href = `/r/${linkToken}`;
},
onCancel: () => { /* buyer closed the popup */ },
onError: (err) => { /* see SDK reference */ },
});See the SDK reference → sdk.openRefundRequest()
for the full options table.
Customer renewal — buyer-driven re-issue
If the buyer opens the URL after the token expired, the page offers a Request new link button instead of the form. Clicking it:
- Sends the renewal request (no credentials needed; the link itself authorizes it)
- Optionally captures a free-text note (
customer_note) the buyer can leave for you - Moves the token to
RENEWAL_REQUESTEDand firesrefund_request.renewal_requestedto your webhook
Your dashboard shows a badge on the renewal-requests widget. Approve
it (one click) and a new ACTIVE token is issued, fires
refund_request.renewed, and lets you copy the new URL to send
again. The old URL stays accessible but renders
“Replaced — check your email” so a forwarded copy of the old URL
can’t be used to fish out the new one.
Executing a crypto refund
The API records the intent — it doesn’t move funds. You sign and broadcast the on-chain transfer from your own wallet, then stamp the tx hash back onto the refund record:
POST /b2b/v1/refunds/:refund_id/submit-tx
Content-Type: application/json
{
"tx_hash": "0xabcd…",
"network": "ethereum",
"token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
}All three body fields are required: the same tx hash can exist on different chains, and you may refund in a different stablecoin than the original payment captured in.
When InfraIO Pay sees that transaction reach the required
confirmation count (see Chains & assets),
the refund flips to EXECUTED and the Order’s refunded-total is
updated.
We deliberately don’t hold custody of merchant funds, which means
we can’t execute refunds on your behalf. Build the on-chain send
into your admin tooling — eth_sendRawTransaction from a
multisig or hot wallet, with a workflow that ends in posting the
tx hash to the refund API.
Refunds on TRON, Solana and TON
The flow is the same: you send the refund from your own wallet, then submit the transaction hash. The details follow the network:
- The refund screen in the dashboard shows the destination, amount, network and token, plus a QR code where the network supports one: a Solana Pay QR on Solana and a TON transfer link on TON. On TRON it shows the destination address to copy (no wallet link carries the amount), so enter the amount yourself.
token_addressis the token’s address on that network: the TRC-20 contract, the SPL mint, or the Jetton master address.- Transaction hash formats differ: bare hex on TRON, a base58 signature on Solana, a hex or base64 hash on TON.
- The platform verifies that exact transaction on-chain, then moves the refund to
EXECUTED, using the confirmation counts in Chains & assets.
Webhook events
The refund subsystem fires two event families:
Token lifecycle (refund_request.*)
| Event | Fires when |
|---|---|
refund_request.created | A token was minted — data.source is b2b / dashboard / renewal |
refund_request.renewal_requested | A buyer clicked “Request new link” after their token expired. Subscribe to this — it’s the merchant’s cue to act. |
refund_request.renewed | You approved a renewal and a new token replaced the old one. data.old_token / data.new_token form the audit chain. |
refund_request.canceled | You flipped a token to CANCELED from the dashboard. Idempotent — only the first transition emits. data.reason is the optional merchant note. |
Refund lifecycle (payment.refund.*)
| Event | Fires when |
|---|---|
payment.refund.requested | A new Refund row exists — either source (form submit, merchant-initiated API, dashboard). |
payment.refund.approved | The refund is approved — either auto-approved (merchant-initiated) or after you call /approve on a pending one. |
payment.refund.rejected | You called /reject on a pending refund. |
payment.refund.executed | Funds have moved (your crypto tx hash hit the required confirmations). |
payment.failed does not fire for a refund — refunds have their
own event series under the payment.refund.* prefix.
What’s next
- SDK reference →
sdk.openRefundRequest()— open the hosted refund form as popup / redirect / embed. - API reference → Refunds — endpoint catalog (mint, submit, renewal, status).
- Concepts → Orders — how Refund state ties back into the Order lifecycle.
- Webhooks → Overview — full event catalog.