How to Refund Stablecoin Payments Safely: From Request to On-Chain Confirmation
Learn how to refund finalized stablecoin payments with verified addresses, exact amounts, signed transactions, idempotent webhooks, and on-chain reconciliation.
Card payments often have a provider-level refund operation. Stablecoin transfers work differently. Once an on-chain transfer is confirmed, it cannot be canceled inside the original transaction. The merchant must send a new transfer from a wallet it controls to a verified refund address.
That makes a refund more than a reverse payment-order status. It is a separate funds operation with its own permissions, status, transaction evidence, webhooks, and reconciliation records.
StableOps keeps this boundary explicit in its non-custodial refund flow. The merchant controls the wallet for the original receiving address. StableOps records the refund request, validates the refundable amount, and checks the on-chain result against the transaction hash submitted by the merchant. StableOps does not hold the merchant's private keys or send the refund transaction for the merchant.
A refund is a new on-chain transfer
A stablecoin refund produces at least two related records:
Original payment
Payer wallet -> merchant receiving address
payment.finalized
Refund
Original receiving address -> customer-provided refund address
refund.confirmedThe original order says what the customer was asked to pay. The refund record says what the merchant decided to return and whether the new on-chain transfer completed. The records should be linked, but neither should overwrite the other.
| Record | Purpose | What it should not do |
|---|---|---|
| Payment order | Stores the original amount, chain, asset, receiving address, and payment lifecycle | Store the refund transaction hash as if it were an incoming payment |
| Refund record | Stores the refund amount, destination, status, and transaction evidence | Rewrite the original order as unpaid |
| Merchant ledger | Records sales, refunds, manual credits, and fees | Infer customer identity from a wallet balance change |
| On-chain record | Proves the actual transfer and canonical status | Know the customer's order or refund policy |
This is why stablecoin payment reconciliation should report receipts, refunds, manual credits, and treasury movements separately. A wallet balance is a position, not an event log.
Refund only finalized orders
The first refund boundary is the payment-order status. A payment at detected or confirmed can still become reverted after a failed receipt or a reorganization. It is unsafe to treat an unsettled payment as refundable funds.
StableOps allows a refund request only for a finalized payment order. This prevents several failure modes:
- the payment later reverts after the merchant has already sent money back.
- refund processing races with the original payment confirmation and creates duplicate outflows.
- support bypasses finality after relying on a wallet screen or a customer-provided transaction hash.
finalized means the incoming payment reached the platform's finality boundary. It does not approve a refund or decide why the customer should receive one. The merchant still owns the refund reason, customer identity, support approval, and treasury policy.
This refund API does not cover underpayments, overpayments, wrong-network transfers, or payments received after expiry because those transfers did not finalize the original order. Resolve them with merchant-controlled treasury tools on the chain and address where the funds actually arrived, then record the result in the exception ledger. See Underpaid, Overpaid, or Wrong Network for that workflow.
For the difference between confirmation depth and finality, see Stablecoin Payment Confirmations.
Refund statuses should describe observable facts
A refund status should not merely mean that someone clicked a support button. Each state should correspond to a fact that the application or the chain can verify.
requested -> submitted -> confirmed
| |
canceled failed -> submitted| Status | Meaning | Next action |
|---|---|---|
requested | The amount and destination are recorded. No on-chain transaction has been submitted | Sign and submit the transaction, or cancel the request |
submitted | The merchant registered a transaction hash. Receipt and transfer checks are pending | Wait for confirmation, or resubmit after a failure |
confirmed | The transaction succeeded and chain, asset, source, destination, and amount all match | Complete the refund and reconciliation workflow |
failed | The receipt failed or the transfer did not satisfy the refund instruction | Correct the issue and submit again |
canceled | An unsubmitted refund request was canceled | Create a new request if needed |
submitted does not mean that the customer received the funds. The hash may not exist, the receipt may fail, or the transaction may contain the wrong token transfer. Only confirmed should drive the refund-completed side of the business workflow.
Refund events include refund.requested, refund.submitted, refund.confirmed, and refund.failed. Verify and deduplicate these events, then make the actual entitlement or ledger operation idempotent. The general pattern is covered in Stablecoin Payment Webhooks: Prevent Duplicate Fulfillment.
Keep the refundable amount correct under concurrency
A single payment order may receive several refunds. A merchant might return part of the payment first and the remainder later. The system must check the sum of all reserved refunds, not only whether each individual request is below the original order amount.
The remaining amount can be expressed as:
Remaining refundable amount = original order amount -
the sum of requested, submitted, and confirmed refundsfailed does not reserve refund capacity. Before resubmitting a failed refund, StableOps locks the original payment order and recalculates the remaining amount. If another refund has consumed that capacity, the resubmission is rejected so old and new refunds cannot exceed the original receipt.
Two support agents creating refunds for the same order at the same time can trigger a race. If both requests read the same remaining balance before either writes, their combined refunds can exceed the original payment.
A safe implementation performs these operations in one database transaction:
- Lock the payment order.
- Confirm the organization and environment, and require
finalized. - Find the matched payment event for the order.
- Derive the chain and asset from that event.
- Sum refunds that still reserve amount.
- Reject the request if it exceeds the remaining amount.
- Write the refund and the
refund.requestedevent.
Amounts should be compared as integer minor units for the relevant token. Do not use JavaScript floating-point numbers for stablecoin amounts. Do not assume that the same display precision means the same token precision across chains.
Do not blindly refund to the payment source address
The fastest-looking approach is to send the refund to the original transaction's from address. That address may not identify the real customer:
- an exchange may use one hot wallet for many withdrawals.
- a smart-contract wallet may use a relayer.
- an intermediary may have broadcast the transaction for the customer.
- the customer may explicitly request a different address that they control.
Ask the customer for a destination address and validate at least the following:
- it is on the same chain as the original payment.
- its format and address type are valid for that chain.
- it is not a merchant address that could turn an operator mistake into a new deposit.
- customer, organization, and risk checks satisfy the merchant's policy.
- the refund amount, asset, and fee treatment have approval.
StableOps validates the destination format against the original payment chain when it creates the refund request. EVM addresses are then normalized according to the established rules. Networks such as TRON and Solana retain their original casing. Format validation does not prove customer ownership, so destination ownership and risk approval remain merchant responsibilities.
Separate the refund request from transaction signing
The most important non-custodial boundary is the separation between requesting a refund and signing the transaction.
First, the backend creates the refund request. It binds the request to a finalized payment order, refund amount, original payment event, chain, asset, original receiving address, and destination. Creating the request does not move funds. Run this code only on the merchant server. Never expose the API key to a browser or mobile application.
async function parseStableOpsResponse(response: Response) {
if (!response.ok) {
const detail = await response.text()
throw new Error(`StableOps API request failed (${response.status}): ${detail}`)
}
return response.json()
}
const response = await fetch(`${API_URL}/v1/refunds`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
payment_order_id: 'po_123',
amount: '10.00',
destination_address: '0xCustomerRefundAddress',
}),
})
const refund = await parseStableOpsResponse(response)
// refund.status === 'requested'Second, the merchant signs and broadcasts the transaction from the original receiving address. After it is submitted, the merchant registers the transaction hash:
const submittedResponse = await fetch(`${API_URL}/v1/refunds/${refund.id}/submit`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tx_hash: txHash }),
})
const submitted = await parseStableOpsResponse(submittedResponse)
// submitted.status === 'submitted'These actions have different responsibilities:
| Action | Controlled by | Moves funds? |
|---|---|---|
| Create refund request | Merchant business backend | No |
| Sign and broadcast transaction | Merchant wallet or treasury service controlling the original receiving address | Yes, by submitting an on-chain transfer |
| Register transaction hash | Merchant backend | No, it provides evidence to check |
| Confirm refund | StableOps chain reconciliation | No, it records the verified result |
The API paths are POST /v1/refunds, POST /v1/refunds/:id/submit, and POST /v1/refunds/:id/cancel. See Create a non-custodial refund request and Register a merchant-signed refund transaction for fields and response schemas. In production, place signing behind treasury approval. A customer-facing request should never receive direct hot-wallet signing authority.
Verify the complete transfer when confirming a refund
Registering a transaction hash is not enough to prove that a refund completed. Reconciliation should verify at least:
| Field | Check |
|---|---|
| Transaction hash | Look up the transaction on the original payment chain stored with the refund |
| Receipt | Execution succeeded rather than returning a failed receipt |
| Finality depth | The current receipt block reached the configured finality depth for that chain |
| Source address | The token transfer came from the original payment receiving address |
| Destination address | The token transfer destination equals the refund request destination |
| Asset | The token contract or mint equals the original payment asset |
| Amount | The minor-unit amount equals the requested refund amount |
A transaction can contain several token transfers. Its overall success does not make it a successful refund. The system must find the transfer that exactly matches the refund instruction.
If the receipt is missing or unknown, the refund remains submitted for another check. Mark it failed only when the receipt fails or, after finality depth, the transaction lacks a transfer matching chain, asset, source, destination, and amount. Do not submit another transaction until the actual funds movement is understood.
Webhooks still need two layers of idempotency
Refund events can be delivered more than once because of timeouts, receiver failures, or manual replay. The receiver should preserve the raw request body, verify the signature, and deduplicate by X-Event-Id.
Event deduplication does not replace business idempotency. Two distinct valid refund.confirmed events should not cause the same internal refund record to post two ledger reversals. Use two protections:
- Store each event ID uniquely in an inbox.
- Apply the business update idempotently by refund ID or internal refund reference.
Commit the event record and the ledger task in one database transaction before returning success. Customer notifications, order updates, and emails can run through a retryable outbox. They should not be the last side effects of the HTTP request itself.
The business-layer logic can look like this:
function applyRefundConfirmed(refundId: string, eventId: string) {
// In one transaction:
// 1. Insert eventId into the inbox if it is new.
// 2. Post the refund ledger entry if refundId is not complete.
// 3. Create one notification or reconciliation task.
// 4. Make a duplicate event a no-op.
}Do not finalize refund accounting from a browser redirect, a customer-provided hash, or an unsigned event. These signals help support investigate. They do not replace server-side chain verification.
Handle common refund exceptions explicitly
Keep the failure reason next to the original payment record so support, engineering, and finance can investigate the same evidence.
| Exception | Correct handling |
|---|---|
Original order is not finalized | Wait for finality or cancel the request according to policy |
| Refund exceeds the remaining amount | Reject it and include requested, submitted, and confirmed refunds in the check |
| Destination is on the wrong chain | Cancel the unsubmitted request and collect a destination on the original chain |
| Receipt failed | Mark failed, retain the reason, and resubmit after correction |
| Transaction lacks the expected transfer | Mark failed and do not treat the transaction as a refund |
| Receipt is temporarily missing | Keep submitted, wait for another poll, and do not immediately broadcast again |
| Customer requests a new destination | Repeat address validation and approval. Do not overwrite the original refund record |
| Refund is confirmed but the customer cannot see it | Provide the hash and explorer link, then verify the customer's network and asset |
An original payment reversion is not a refund failure. payment.reverted means the incoming payment no longer holds on the canonical chain. It does not prove that the merchant still holds funds to return. See Underpaid, Overpaid, or Wrong Network and the reverted payment FAQ for this boundary.
Include refunds in routine reconciliation
Every refund should be traceable through this chain:
Merchant business order
-> StableOps payment order
-> original payment.finalized event
-> refund record
-> refund transaction hash
-> refund.confirmed eventDaily reconciliation can check:
- finalized receipts against revenue in the merchant ledger.
- every refund against a valid original payment event.
- every confirmed refund against a successful on-chain transfer.
- requested or submitted refunds that have stalled.
- total refunds against the original order amount.
- refund fees against the merchant's fee policy.
- every refund transaction on the correct chain and asset.
At month end, do not export only confirmed refunds. Keep failed, canceled, and in-progress refunds in the close package, together with the operator, approval time, failure reason, and transaction references. Stablecoin Payment Reconciliation explains how to connect orders, payment events, and on-chain transfers.
Launch checklist
- Only
finalizedpayment orders can create refund requests. - Refund capacity is serialized per order, and in-progress refunds reserve amount.
- Amounts are compared in token minor units without floating-point arithmetic.
- The customer provides a destination address that passes chain, format, and risk checks.
- Refund creation and signing from the original receiving address use different permission boundaries.
- The server checks receipt, finality depth, source, destination, asset, and amount after submission.
- A refund becomes
confirmedonly after the complete transfer matches. -
refund.*webhooks use raw-body verification, event deduplication, and business idempotency. - Failures and cancellations never overwrite the original payment record.
- Every refund links the merchant order, payment order, original payment event, and refund transaction hash.
- Sandbox tests cover partial refunds, concurrent refunds, incorrect transactions, and duplicate webhooks.
The core of stablecoin refunds is not adding a refund button. It is building an auditable flow for funds that cannot be reversed in place. The original receipt remains immutable. The refund is approved, signed, checked, and notified as a separate transaction. When a transaction fails, a webhook retries, or support repeats an action, the merchant can still answer three questions: what was received, what was approved for return, and what finally happened on-chain.
Related articles
Learn crypto payment reconciliation across business orders, payment events, and on-chain transfers with a safe script and daily and monthly checklists.
An underpaid crypto payment, overpayment, wrong-network transfer, or late payment should never silently complete an order. Learn how to detect, resolve, and prevent each mismatch.
Learn why production stablecoin payments need explicit order states, deterministic transfer matching, finality tracking, idempotency, and reliable webhooks.
Learn how crypto deposit monitoring uses unique addresses, finality, idempotent webhooks, and reconciliation to credit customer deposits safely.