Skip to content

Refunds and chargebacks on Dodo, and what they should do to access

A refund or dispute webhook says which payment, not what was bought. Re-read the payment, derive the status from all its refunds and disputes, and never let a late event undo a refund.

Dodo Payments3 min readships at docs/solutions/dodo/refunds-and-disputes-bookkeeping.md

Tags: dodo · refunds · disputes · chargebacks · entitlements · webhooks

The first refund is easy. Someone asks, you click refund in the Dodo dashboard, they get their money. The question is what your app does next. If it does nothing, a refunded lifetime customer keeps the product for free, and so does anyone who learns that a chargeback costs them nothing.

What Dodo tells you

EventPayloadUseful fields
refund.succeededa Refundpayment_id, amount, is_partial
dispute.openeda Disputepayment_id, dispute_status, dispute_stage
dispute.losta Disputethe same, dispute_lost

Neither payload says which product was bought, which user it was, or whether this is the second partial refund on the same payment. The payment knows all of that. So the handler does one thing first: re-read the payment.

const payment = await dodo.payments.retrieve(event.data.payment_id);

The payment carries refunds (each with a status and an amount), disputes (each with a status), refund_status (partial or full), the product in product_cart, and our checkout metadata. The status follows from those, whatever order the events arrived in:

if (refunded >= payment.total_amount) return "refunded";
if (disputes.some(isLive)) return "disputed";   // opened, challenged, accepted, lost, expired
if (refunded > 0) return "partially_refunded";
return "paid";

Only refunds with status: "succeeded" count. A pending or failed refund did not move money.

Why derive, not increment

The tempting version adds each refund's amount to a running total. It breaks the first time Dodo retries a delivery: the same refund is counted twice, and a $50 goodwill credit becomes a $100 one that looks like a full refund. Deriving the total from the payment's own list of refunds gives the same answer however many times the event arrives.

The idempotency ledger (billing_processed_events, keyed on webhook-id) stops most duplicates before they run. Deriving the state stops the rest.

Status only moves forward

Purchases are written by one SQL upsert, shared by both ORMs, with a rank:

pending < failed < paid < partially_refunded < disputed < refunded

A write never lowers the rank. So a payment.succeeded retried a day late cannot turn a refunded purchase back into a paid one, and a refund always wins. amount_refunded keeps the larger value; refunded_at keeps the first one.

The known limit: a dispute you later win stays disputed. Winning is rare enough that fixing the row by hand is the right trade:

update billing_purchases set status = 'paid' where provider_order_id = 'pay_...';

What each status does to access

  • paid, partially_refunded: the plan is granted. A partial refund is usually a goodwill credit, not a cancellation.
  • refunded, disputed: nothing is granted. The UI shows the purchase with its status, so the customer sees why.

Refunds on subscription payments change nothing here. A subscription's access follows its own status, and Dodo moves that with subscription.* events if the refund comes with a cancellation.

Bookkeeping

This repo records what access someone has, not your accounts. Dodo is the merchant of record: it holds the tax, the invoices, the payouts and the reconciliation reports. For revenue, fees and refunds by period, use Dodo's reports, not a sum over billing_purchases, which stores gross amounts in the customer's currency and nothing about fees or tax.

Testing it

  1. Buy the lifetime price in test mode (or send a signed fixture with bun run dodo:test-webhook -- --user <id>).
  2. Refund half of it in the dashboard. The row goes partially_refunded, access stays.
  3. Refund the rest. The row goes refunded, /billing drops the plan.
  4. Resend the first refund.succeeded from the dashboard. It answers duplicate, and the row does not change.