<!-- Source: https://docs.infraio.xyz/en/concepts/refunds -->
<!-- Last updated: 2026-10-04 -->

# 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.

> **Note:**
>
> Refunds can also be issued from the [merchant app](https://docs.infraio.xyz/en/get-started/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

```mermaid
stateDiagram-v2
    [*] --> PENDING:  refund created (customer submit or B2B customer-flow)
    PENDING --> APPROVED: passes review (auto for merchant-initiated)
    PENDING --> REJECTED: review denies
    APPROVED --> EXECUTED: on-chain tx confirmed
    APPROVED --> REJECTED: canceled before execution
    REJECTED --> [*]
    EXECUTED --> [*]
```

| 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.

```http
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](#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

```mermaid
stateDiagram-v2
    [*] --> ACTIVE:           mint (B2B or dashboard)
    ACTIVE --> SUBMITTED:     buyer submits the form
    ACTIVE --> EXPIRED_UNUSED: now > expires_at
    ACTIVE --> CANCELED:      merchant cancels (dashboard)
    EXPIRED_UNUSED --> RENEWAL_REQUESTED: buyer clicks "Request new link"
    RENEWAL_REQUESTED --> RENEWED: merchant approves, new ACTIVE token issued
    SUBMITTED --> [*]
    CANCELED --> [*]
    RENEWED --> [*]
```

| 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" |

> **Note:**
>
> 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.

```http
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
}
```

> **Warning:**
>
> 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](https://docs.infraio.xyz/en/api-reference/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:

```json
{
  "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](https://app.infraio.xyz)
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()`:

```ts
// 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()`](https://docs.infraio.xyz/en/sdks/javascript#sdkopenrefundrequest-)
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:

1. Sends the renewal request (no credentials needed; the link itself
   authorizes it)
2. Optionally captures a free-text note (`customer_note`) the buyer
   can leave for you
3. Moves the token to `RENEWAL_REQUESTED` and fires
   `refund_request.renewal_requested` to 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:

```http
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](https://docs.infraio.xyz/en/concepts/chains)),
the refund flips to `EXECUTED` and the Order's refunded-total is
updated.

> **Warning:**
>
> 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_address` is 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](https://docs.infraio.xyz/en/concepts/chains).

---

## 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()`](https://docs.infraio.xyz/en/sdks/javascript#sdkopenrefundrequest-) — open the hosted refund form as popup / redirect / embed.
- [API reference → Refunds](https://docs.infraio.xyz/en/api-reference#refunds) — endpoint catalog (mint, submit, renewal, status).
- [Concepts → Orders](https://docs.infraio.xyz/en/concepts/orders) — how Refund state ties back into the Order lifecycle.
- [Webhooks → Overview](https://docs.infraio.xyz/en/webhooks/overview) — full event catalog.
