Skip to content

Refunds

A refund is its own record (ref_…), never a mutation of the payment. The payment stays the immutable fact of what was collected; a refund is a fresh outbound swap that reverses its value.

The custody model

0billing never holds funds — settlement went straight to your treasury, and refunds don't change that. A refund is funded by one transfer you sign from the treasury you control. There is no delegated spending authority, no hot wallet key in config, nothing that could drain a treasury if this server were compromised.

What 0billing does is everything around that one transfer:

  1. Quote the reverse swap — treasury asset in, the customer's original asset out, delivered to their address — and reserve a one-time deposit address for it (status: awaiting_funds).
  2. You fund it: send source.amount of source.asset from the treasury to source.depositAddress. The dashboard shows this instruction on every awaiting refund; any wallet works.
  3. Watch it settle exactly like an inbound payment (detectedcompleted), and tell your app via webhook so entitlement reversal is driven the same way granting is.

An unfunded refund quote simply expires — nothing moved, nothing owed.

The currency model

Refunds are denominated in USD — the unit everything else in 0billing is priced in — and delivered in the asset and chain the customer paid from, at the rate at refund time.

  • The customer paid $20 in BTC three weeks ago: a full refund delivers $20 worth of BTC today. Neither side is exposed to the price move between payment and refund; the refund is of the purchase value, not the coins.
  • The swap overhead is the merchant's: source.amount is the refund value plus routing costs, and destination.amount — what the customer receives — is the full quoted value. Refunding should make the customer whole.
  • Delivery defaults to the refundTo address the payer supplied at checkout (the only customer address we hold), overridable per refund with toAddress — e.g. when the customer's exchange deposit address rotated.

Partial refunds

amountUsd may be any part of the payment; refunds stack until the payment is fully returned. failed and expired refunds don't count against the cap — in both cases the treasury kept (or got back) its money.

Failure is safe

If the outbound swap fails, the router returns the funds to the treasury — the refund goes failed, the customer received nothing, and the amount is refundable again. The webhook (refund.failed) tells you to try again rather than to reverse anything.

API and dashboard

  • POST /v1/payments/:id/refund { amountUsd?, toAddress? } — admin-only: refunds move treasury money.
  • GET /v1/refunds?paymentId=… and GET /v1/refunds/:id (polling the latter drives it forward).
  • In the dashboard, expand a confirmed payment → Refund… — it shows every refund's state and the exact funding instruction while one is awaiting.
  • Events: refund.created, refund.completed, refund.failed, refund.expired — see webhooks.

0billing — crypto billing for your app · pay from any chain, settle in one