A lifetime deal that survives its own refund is a free product with extra steps. So is one bought with a stolen card and charged back. Access has to follow the money, and on a one-time purchase the money moves after checkout.
Refunds: order.refunded
Refund an order in the Polar dashboard (or through the API) and Polar sends
order.refunded with the whole order as it now stands:
{ "type": "order.refunded", "data": {
"id": "5c1a8e2b-...", "status": "refunded", "billing_reason": "purchase",
"total_amount": 32591, "refunded_amount": 29900, "refunded_tax_amount": 2691,
"subscription_id": null, "metadata": { "userId": "user_ada", "priceId": "pro-lifetime" }
} }
The adapter writes that state, not a delta, onto the same purchase row the
order.paid created (keyed on the order id):
| Order | Purchase row | Access |
|---|---|---|
status refunded, or refunded amounts reach the total | refunded | gone |
status partially_refunded, or any refunded amount | partially_refunded | kept |
Note the tax. total_amount includes the tax Polar collected, and
refunded_amount does not; refunded_tax_amount is the rest. The row stores
amount = total_amount and amount_refunded = refunded_amount +
refunded_tax_amount, so a full refund reads as equal numbers.
A partial refund keeps access on purpose. It is usually a goodwill credit, not a cancellation. If your policy says otherwise, refund in full.
A refund on a subscription order (subscription_id set) does not touch the
purchases table. The adapter re-reads the subscription and writes whatever
Polar says about it; cancelling or revoking is a separate action in Polar.
Out of order, and twice
Polar retries failed deliveries and can deliver out of order. Two defences:
- The claim. Each delivery is claimed as
polar:<webhook-id>before any write. The same delivery twice answersduplicateand writes nothing. - Status only moves forward. The purchase upsert ranks
pending < failed < paid < partially_refunded < disputed < refundedand never goes back. A sloworder.paidthat lands afterorder.refundedis a different delivery, so the claim lets it through, and the upsert still leaves the rowrefunded.amount_refundedonly grows;paid_atandrefunded_atkeep their first value.
That is why the adapter can write the order exactly as delivered, without a second call to Polar.
Disputes: no webhook
Polar handles chargebacks as merchant of record, and the SDK this repo pins has no dispute event. So nothing arrives when a customer disputes a lifetime purchase. The nightly job closes the gap:
bun run billing:reconcile
Besides re-reading subscriptions and recent orders, it lists disputes that are
needs_response, under_review or lost, fetches each order, and writes its
purchase as disputed, which grants nothing. Won and prevented disputes change
nothing. Schedule it nightly; a dispute then costs at most a day of access.
A dispute you later win leaves the row disputed, because status never moves
back. Fix that row by hand (status = 'paid') once Polar shows it won.
What the customer sees
/billing reads the same rows. After a full refund the plan card falls back
to the free plan and the purchase history shows the refund. Nothing is emailed
from this app for a refund; the receipt for it is in Polar's customer portal.
Test it
bun run billing:test-webhook -- --user <user id> # paid
bun run billing:test-webhook -- --user <user id> --order <order id> --refund # refunded
Then with a real sandbox order: refund it in the dashboard and watch
order.refunded answer 200 and the plan leave /billing.