Skip to Content
ConceptsRefunds

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:

FlowWho fills the formAuthLands in
Merchant-initiatedYour dashboard / your backendHMAC (sk_…)APPROVED immediately
Customer-initiatedThe buyer, on our hosted pageOne-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

StateMeans
PENDINGRefund recorded, awaiting approval. Customer-initiated refunds always start here.
APPROVEDCleared for execution. Merchant-initiated refunds jump here directly.
REJECTEDRefund denied. Order status unchanged.
EXECUTEDOn-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

StateMeansCustomer URL renders
ACTIVEToken is live, now < expires_atThe refund form (refund_to_address, reason, amount, optional note → metadata.note)
SUBMITTEDBuyer completed the form; a Refund row existsStatus card mirroring /r/:linkToken
EXPIRED_UNUSEDTTL elapsed before the buyer submittedPrompt: “This link has expired. Request a new one”
RENEWAL_REQUESTEDBuyer asked for a fresh linkWaiting notice: “Your merchant has been notified”
RENEWEDMerchant 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)
CANCELEDMerchant revoked the token from the dashboardPlain “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 sourceDefault TTLWhy
POST /b2b/v1/merchants/{merchant_id}/refund-requests (HMAC)30 minutesProgrammatic — assumed to be handed to the buyer immediately.
POST /payment/v1/merchants/{merchant_id}/refund-requests (dashboard JWT)24 hoursManual — 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:

  1. POSTs to /pub/v1/refund-requests/:token/request-renewal (no credentials — the token itself is the bearer-of-truth)
  2. Optionally captures a free-text note (customer_note) the buyer can leave for the merchant
  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 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.*)

EventFires when
refund_request.createdA token was minted — data.source is b2b / dashboard / renewal
refund_request.renewal_requestedA buyer clicked “Request new link” after their token expired. Subscribe to this — it’s the merchant’s cue to act.
refund_request.renewedYou approved a renewal and a new token replaced the old one. data.old_token / data.new_token form the audit chain.
refund_request.canceledYou 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.*)

EventFires when
payment.refund.requestedA new Refund row exists — either source (form submit, merchant-initiated API, dashboard).
payment.refund.approvedThe refund is approved — either auto-approved (merchant-initiated) or after you call /approve on a pending one.
payment.refund.rejectedYou called /reject on a pending refund.
payment.refund.executedFunds 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