Refunds
Pull refunds through a private claim link — why they are not automatic, how the link reaches the buyer, the network fee, and wrong-token transfers.
Last updated Oct 27, 2026
Any money that reaches a deposit address and is not owed to the seller is held for the buyer in LIABILITY:REFUND_ESCROW:<invoice> and becomes a refund:
reason | When |
|---|---|
OVERPAYMENT | The invoice is paid with more than its total: the excess (invoice OVERPAID). |
EXPIRED_UNDERPAID | A partial payment when the invoice expires (invoice EXPIRED_UNDERPAID, then REFUNDED once paid back). |
LATE_PAYMENT | A transfer confirmed after the invoice expired, settled or was voided. |
WRONG_TOKEN | Another canonical token of the same network sent to the address (USDC to a USDT invoice). |
Pull, not push
Refunds are never sent back automatically to the address that paid. That address is often an exchange's hot wallet or a contract that cannot receive (or credit) the money. Instead the buyer claims the refund: they open a private link, paste a wallet they control on the payment's network, and the worker sends it there.
The claim link
<CRYPTO_CHECKOUT_BASE_URL>/pay/<invoice id>/refund/<claim token>. The token (256 bits) is the buyer's only credential, so it is kept out of every place the invoice id travels:
- The public checkout (
GET /api/v1/public/pay/:id, the/pay/:idpage) shows that a refund is held, how much, its status and where the link comes from —refund.deliveryisemailorseller— but never the link. - With
buyer_emailon the invoice, the buyer gets the link by email when the refund becomes claimable (delivery: "email"). - Otherwise the seller forwards it (
delivery: "seller"). The merchant reads it fromGET /api/v1/crypto/invoices/:id(refunds[].claim_url),GET /api/v1/crypto/refunds, therefund.claimablewebhook (which also carriesbuyer_email), or the dashboard's Copy claim link button. The dashboard's raw-response view redacts it.
Public refund endpoints are keyed by the claim token only: GET /api/v1/public/refunds/:claimToken and POST /api/v1/public/refunds/:claimToken/claim.
Claiming
POST …/claim { "destination_address": "0x…" } checks the address (valid, EIP-55 checksum when mixed case, not one of the gateway's own addresses, not a contract — a contract answers 422), fixes the network fee and the net amount, and moves the refund CLAIMABLE → PROCESSING (202). Claiming again to the same address is idempotent.
The network fee is the cost of sending the refund, charged to the refund: estimated gas × max fee per gas × CRYPTO_NATIVE_PRICE_USD_<FAMILY> (Polygon 0.50, BSC 600, Base 3000 by default), at least 0.01, booked as REVENUE:REFUND_NETWORK_FEE. The portal shows held, fee and "you receive" before the buyer confirms. When the fee would eat the whole refund (dust), the claim is refused with 422.
The worker sends a refund only after every transfer of that invoice and token has been swept and the sweep is confirmed, then marks it COMPLETED at the network's confirmation threshold (refund.completed webhook). An EXPIRED_UNDERPAID invoice becomes REFUNDED.
Wrong token
A transfer of another canonical token never detects, pays or settles the invoice. Once confirmed it is escrowed in its own token (REFUND_ESCROW:<invoice>:<network>:<token>) with a WRONG_TOKEN refund paid back in that token, whatever the invoice's status. Nothing is credited to any seller in that token. Sweeps run per invoice × token, so the refund waits only for the sweep of its own token. Tokens that are not in the registry are not watched.
[!NOTE] A refund that fails on chain becomes
FAILEDand its escrow stays owed. There is no retry button yet; it needs an operator.