Back to Blog
Engineering
2026-08-2912 min readBy StableOps

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.

Stablecoin payments
Payment refunds
Payment operations

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

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

RecordPurposeWhat it should not do
Payment orderStores the original amount, chain, asset, receiving address, and payment lifecycleStore the refund transaction hash as if it were an incoming payment
Refund recordStores the refund amount, destination, status, and transaction evidenceRewrite the original order as unpaid
Merchant ledgerRecords sales, refunds, manual credits, and feesInfer customer identity from a wallet balance change
On-chain recordProves the actual transfer and canonical statusKnow 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
StatusMeaningNext action
requestedThe amount and destination are recorded. No on-chain transaction has been submittedSign and submit the transaction, or cancel the request
submittedThe merchant registered a transaction hash. Receipt and transfer checks are pendingWait for confirmation, or resubmit after a failure
confirmedThe transaction succeeded and chain, asset, source, destination, and amount all matchComplete the refund and reconciliation workflow
failedThe receipt failed or the transfer did not satisfy the refund instructionCorrect the issue and submit again
canceledAn unsubmitted refund request was canceledCreate 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 refunds

failed 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:

  1. Lock the payment order.
  2. Confirm the organization and environment, and require finalized.
  3. Find the matched payment event for the order.
  4. Derive the chain and asset from that event.
  5. Sum refunds that still reserve amount.
  6. Reject the request if it exceeds the remaining amount.
  7. Write the refund and the refund.requested event.

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:

ActionControlled byMoves funds?
Create refund requestMerchant business backendNo
Sign and broadcast transactionMerchant wallet or treasury service controlling the original receiving addressYes, by submitting an on-chain transfer
Register transaction hashMerchant backendNo, it provides evidence to check
Confirm refundStableOps chain reconciliationNo, 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:

FieldCheck
Transaction hashLook up the transaction on the original payment chain stored with the refund
ReceiptExecution succeeded rather than returning a failed receipt
Finality depthThe current receipt block reached the configured finality depth for that chain
Source addressThe token transfer came from the original payment receiving address
Destination addressThe token transfer destination equals the refund request destination
AssetThe token contract or mint equals the original payment asset
AmountThe 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:

  1. Store each event ID uniquely in an inbox.
  2. 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.

ExceptionCorrect handling
Original order is not finalizedWait for finality or cancel the request according to policy
Refund exceeds the remaining amountReject it and include requested, submitted, and confirmed refunds in the check
Destination is on the wrong chainCancel the unsubmitted request and collect a destination on the original chain
Receipt failedMark failed, retain the reason, and resubmit after correction
Transaction lacks the expected transferMark failed and do not treat the transaction as a refund
Receipt is temporarily missingKeep submitted, wait for another poll, and do not immediately broadcast again
Customer requests a new destinationRepeat address validation and approval. Do not overwrite the original refund record
Refund is confirmed but the customer cannot see itProvide 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 event

Daily 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 finalized payment 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 confirmed only 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.