BchainPay logoBchainPay
EngineeringPaymentsRefundsStablecoinsAPI

Crypto payment refunds: on-chain reversal patterns for merchants

Crypto has no chargebacks. Build idempotent refund intents, manage FX risk, handle partial refunds, and minimise payout gas across EVM and Solana chains.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for Crypto payment refunds: on-chain reversal patterns for merchants

Every payment gateway eventually has to handle the question merchants dread: "can we refund that?" For card processors the answer is mechanical — send a reversal message and the acquiring bank handles the rest. For crypto gateways the answer is more honest: no transaction on any public blockchain can be reversed. A refund is a brand-new outbound transfer from your hot wallet back to the customer. The original payment stays on-chain forever.

That difference in model — refund-as-new-tx rather than refund-as-reversal — has a cascade of engineering consequences: who bears gas, how you handle exchange-rate drift between payment and refund, whether partial amounts are supported, and how idempotency works when a refund job crashes mid-flight. This post covers how we handle all of it in the BchainPay refund pipeline.

Why refunds are harder than you think#

The naive implementation: take the original payment_intent amount, look up the original sender address, and broadcast a transfer for that amount. What could go wrong?

A few things:

FX risk on volatile assets. A customer paid 0.05 ETH when ETH was $2,000 ($100 net). ETH is now $3,000. Refunding 0.05 ETH costs $150. Refunding $100 worth of ETH today means sending 0.0333 ETH. Your refund policy defines which calculation applies — but your code must support both, and the policy must be stored at settlement time, not looked up retrospectively.

Address availability. The payment came from a centralized-exchange deposit address that the customer no longer controls. Sending the refund there burns the funds. You need to request an explicit refund destination.

Duplicate refunds. Refund jobs are retried. If the signing step succeeds but the acknowledgement step fails, a naive retry broadcasts a second transfer. The refund_intent record and an idempotency key prevent this.

Gas economics. On high-fee chains (Ethereum mainnet), the refund transaction cost may exceed the refund amount for small payments. You need a minimum-refund threshold and a policy for handling sub-threshold requests.

The refund intent model#

BchainPay models every refund as a refund_intent — a first-class record that goes through its own lifecycle before a signing key is ever touched:

created → processing → completed
                    ↘ failed

The refund_intent is linked to the original payment_intent by payment_intent_id. It carries the resolved amount, asset, chain, and destination address. Nothing is signed until the record is in processing.

Creating a refund via the API:

const refund = await fetch("https://api.bchainpay.com/v1/refunds", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.BCHAINPAY_SECRET_KEY}`,
    "Idempotency-Key": `refund-${orderId}-attempt-1`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    payment_intent_id: "pi_01HY9KFZRBA2Q…",
    amount: "100.00",                    // in the asset's display units
    reason: "customer_requested",        // or "duplicate", "fraudulent"
    destination: {
      address: "0xBB9bc244D798123fDE783fCc1C72d3Bb8C189413",
      chain:   "ethereum",
      asset:   "USDC",
    },
  }),
});
 
const intent = await refund.json();
// {
//   id:                  "ri_01HYA8RZPKQN…",
//   status:              "created",
//   payment_intent_id:   "pi_01HY9KFZRBA2Q…",
//   amount:              "100.00",
//   asset:               "USDC",
//   chain:               "ethereum",
//   destination:         "0xBB9bc244D…",
//   created_at:          "2026-04-27T10:00:00Z",
//   estimated_gas_usd:   "0.38"
// }

The Idempotency-Key header is the primary guard against double-refunds. The server stores the key, the response body, and the resolved refund_intent_id in an idempotency_keys table. Any retry with the same key returns the same response without re-executing the business logic:

INSERT INTO idempotency_keys (key, response_body, refund_intent_id, expires_at)
VALUES ($1, $2, $3, now() + interval '24 hours')
ON CONFLICT (key) DO NOTHING;

If the ON CONFLICT fires, the existing row wins. The caller gets the same response and the same refund_intent_id without a second signing round.

Managing exchange-rate risk#

For stablecoin payments (USDC, USDT, PYUSD), the refund amount is trivially the same number the customer paid. For volatile assets, you need a policy.

We track the settlement_rate_usd at the moment the original payment advanced to confirmed. That rate is frozen on the payment_intent record. Refund calculation then becomes:

type RefundPolicy = "same_units" | "same_usd_at_payment" | "same_usd_today";
 
function resolveRefundAmount(
  intent:     PaymentIntent,
  policy:     RefundPolicy,
  currentUsd: number,
  refundFraction = 1.0,   // 1.0 = full refund; 0.5 = half
): string {
  switch (policy) {
    case "same_units":
      // Return exactly the same token amount that was paid.
      // Risk: merchant over-pays on up-moves.
      return (BigInt(intent.amount_raw) * BigInt(refundFraction * 1e6) / BigInt(1e6)).toString();
 
    case "same_usd_at_payment": {
      // Return the USD value at time of payment, denominated in today's spot.
      // Most common for ETH/BTC merchants wanting FX-neutral outcomes.
      const usdAtPayment = Number(intent.amount_units) * intent.settlement_rate_usd;
      return (usdAtPayment * refundFraction / currentUsd).toFixed(8);
    }
 
    case "same_usd_today":
      // Return exactly what was paid, in the same asset.
      // Equivalent to "same_units" for stablecoins.
      return (Number(intent.amount_units) * refundFraction).toFixed(8);
  }
}

Store the policy alongside the refund_intent so the exact calculation is auditable. "We used same_usd_at_payment and the settlement rate was $2,000" is a complete answer to a dispute. "We eyeballed it" is not.

Partial refunds#

A partial refund passes an amount less than the original payment amount. The server validates that the sum of all completed refunds for a given payment_intent_id does not exceed the original settled amount:

async function validatePartialRefund(
  db:              Pool,
  paymentIntentId: string,
  requestedAmount: bigint,
): Promise<void> {
  const { rows } = await db.query<{ total: string }>(
    `SELECT COALESCE(SUM(amount_raw), 0)::text AS total
     FROM   refund_intents
     WHERE  payment_intent_id = $1
       AND  status IN ('processing', 'completed')`,
    [paymentIntentId],
  );
  const alreadyRefunded = BigInt(rows[0].total);
 
  const { rows: [pi] } = await db.query<{ amount_raw: string }>(
    `SELECT amount_raw FROM payment_intents WHERE id = $1`,
    [paymentIntentId],
  );
  const originalAmount = BigInt(pi.amount_raw);
 
  if (alreadyRefunded + requestedAmount > originalAmount) {
    throw new RefundExceedsOriginalError(
      `Requested ${requestedAmount}, already refunded ${alreadyRefunded}, ` +
      `original ${originalAmount}`,
    );
  }
}

Use amount_raw (the integer token-unit representation, e.g. 6-decimal USDC as a bigint) rather than the display string to avoid floating-point drift when comparing or summing. One cent of USDC is 10000 in raw units — there is no ambiguity.

Refund destination handling#

Never assume the refund should go back to the original sender address without asking. Three common scenarios break that assumption:

  1. Exchange deposit address. Many customers pay from a CEX deposit address they do not control and cannot receive funds at.
  2. Hardware wallet sweep. The customer swept a cold wallet after paying and now wants refunds to a new address.
  3. Corporate treasury. The payer was a smart contract or multisig that cannot receive arbitrary ERC-20 transfers.

The safest default: require destination.address in every refund request. If your checkout UI collected the customer's wallet address, pre-populate it as a default but display it explicitly for confirmation. If the checkout was address-anonymous (the customer just sent to a generated deposit address), block the refund path in your UI until the customer provides a destination.

For EVM chains, always apply the EIP-55 checksum to the destination address before storing it:

import { getAddress } from "ethers"; // throws on invalid or miscased input
 
function normaliseDestination(raw: string, chain: string): string {
  if (chain.startsWith("ethereum") || chain.startsWith("polygon")
      || chain.startsWith("base")) {
    return getAddress(raw);           // throws if not a valid checksum address
  }
  // For TRON, Solana, Bitcoin: use chain-specific validators
  return raw;
}

A miscased EVM address is a hex-valid but checksum-invalid string. Storing the checksummed form prevents two records for the same address and makes later duplicate detection reliable.

Gas costs and minimum refund thresholds#

Every refund transaction on an EVM chain costs gas paid by your hot wallet. On Ethereum mainnet, an ERC-20 transfer costs roughly 65,000 gas. At 10 gwei and ETH at $2,500, that is about $1.63 per transfer. A $5 refund has a 33% overhead; a $1 refund is uneconomical to execute on L1.

Three options for sub-threshold refunds:

# Batch small refunds into a Multicall3 batch once per hour:
# 65,000 gas per ERC-20 transfer
# Multicall3 overhead ~3,000 gas
# 100 refunds in one batch = (100 × 65,000 + 3,000) / 100 ≈ 65,030 gas each
# Still full cost — but one block inclusion event, not 100
Option Suited for
Batch via Multicall3 Many small refunds on a single chain
Move to L2 (Base, Polygon) Sub-$1 gas cost per transfer
Issue store credit / off-chain coupon Very small amounts or non-crypto-native customers

Decide the minimum threshold per chain and per asset, store it in configuration, and surface it clearly in your refund API error response:

{
  "error": {
    "code": "refund_below_minimum",
    "message": "Minimum refund on ethereum/USDC is $2.00. Requested $0.50.",
    "minimum_usd": "2.00",
    "chain":       "ethereum",
    "asset":       "USDC"
  }
}

Webhooks for refund lifecycle events#

The refund state machine emits three events your merchants need:

{ "event": "refund.initiated",
  "data": {
    "id":                "ri_01HYA8RZPKQN…",
    "payment_intent_id": "pi_01HY9KFZRBA2Q…",
    "status":            "processing",
    "amount":            "100.00",
    "asset":             "USDC",
    "chain":             "ethereum",
    "destination":       "0xBB9bc244D…",
    "tx_hash":           null,
    "initiated_at":      "2026-04-27T10:00:01Z"
  }
}
{ "event": "refund.completed",
  "data": {
    "id":           "ri_01HYA8RZPKQN…",
    "status":       "completed",
    "tx_hash":      "0x9a3f…c14d",
    "block_number": 22081443,
    "completed_at": "2026-04-27T10:00:48Z"
  }
}
{ "event": "refund.failed",
  "data": {
    "id":          "ri_01HYA8RZPKQN…",
    "status":      "failed",
    "failure_reason": "insufficient_hot_wallet_balance",
    "failed_at":   "2026-04-27T10:01:00Z"
  }
}

refund.failed with insufficient_hot_wallet_balance should page your operations team immediately — it means the hot wallet sweep from warm has not executed and you cannot honour pending refunds. Wire this alert separately from the merchant webhook delivery path; do not rely on a merchant's webhook receiver noticing the failure.

Key takeaways#

  • A crypto refund is a new outbound transaction. There is no reversal mechanism in any public blockchain. Store the original payment data you will need (amount, asset, chain, settlement rate) at confirmation time, not at refund time.
  • Use an idempotency key on every refund request. Retry-safe refund creation prevents double-refunds when your signing job crashes between broadcast and acknowledgement.
  • Require an explicit refund destination. Never assume the original sender address is safe to refund to. Show the destination to the customer before executing.
  • For volatile assets, define and store your FX policy at settlement time. same_units, same_usd_at_payment, and same_usd_today produce different outcomes on assets that move. The policy must be auditable.
  • Use amount_raw (integer token units) for all refund math. Floating- point arithmetic on USDC balances produces rounding errors at scale; bigint arithmetic on raw units does not.
  • Set per-chain minimum refund thresholds. Gas costs make sub-$2 refunds uneconomical on Ethereum L1. Use L2 chains or Multicall3 batching for high-volume small-value merchants.
  • Alert on refund.failed with insufficient_hot_wallet_balance as an ops emergency, not just a merchant webhook event. A depleted hot wallet blocks all pending refunds, not just one.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading