Skip to content

Refunds and disputes on Polar, and taking access back exactly once

A refund arrives as order.refunded with the whole order. A dispute arrives as nothing at all. How this repo revokes a lifetime deal in both cases, why partial refunds keep access, and why a late paid delivery cannot give it back.

Polar3 min readships at docs/solutions/polar/refunds-and-disputes-on-polar.md

Tags: polar · refunds · disputes · chargebacks · entitlements · webhooks · billing

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):

OrderPurchase rowAccess
status refunded, or refunded amounts reach the totalrefundedgone
status partially_refunded, or any refunded amountpartially_refundedkept

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:

  1. The claim. Each delivery is claimed as polar:<webhook-id> before any write. The same delivery twice answers duplicate and writes nothing.
  2. Status only moves forward. The purchase upsert ranks pending < failed < paid < partially_refunded < disputed < refunded and never goes back. A slow order.paid that lands after order.refunded is a different delivery, so the claim lets it through, and the upsert still leaves the row refunded. amount_refunded only grows; paid_at and refunded_at keep 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.