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
| Event | Payload | Useful fields |
|---|---|---|
refund.succeeded | a Refund | payment_id, amount, is_partial |
dispute.opened | a Dispute | payment_id, dispute_status, dispute_stage |
dispute.lost | a Dispute | the 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
- Buy the lifetime price in test mode (or send a signed fixture with
bun run dodo:test-webhook -- --user <id>). - Refund half of it in the dashboard. The row goes
partially_refunded, access stays. - Refund the rest. The row goes
refunded,/billingdrops the plan. - Resend the first
refund.succeededfrom the dashboard. It answersduplicate, and the row does not change.