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.
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; the payment-service uses them to drive the
crypto saga. 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 row 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. |
POST /payment/v1/merchants/{merchant_id}/refund-requests (dashboard JWT) | 24 hours | Manual — merchant pastes the URL into an email / SMS. |
Both endpoints accept a ttl_seconds body field if you want to
override. There’s no hard min/max bound enforced server-side today —
common values are 1 minute to 7 days. Stay within that range to
avoid surprising buyers or holding capacity on cancelled tokens.
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 legacy { "order_id": "..." } shape is still accepted for
backward compatibility — internally it’s mapped to
(ref_type=order_id, ref_value=...) — 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 calls POST /payment/v1/merchants/{merchant_id}/refund-requests behind the
scenes (JWT-authenticated, same body shape as B2B above), then 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:
- POSTs to
/pub/v1/refund-requests/:token/request-renewal(no credentials — the token itself is the bearer-of-truth) - Optionally captures a free-text note (
customer_note) the buyer can leave for the merchant - 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 the system mints a new ACTIVE token, 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 merchant 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 the chain watcher sees that tx clear the configured
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.
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.