Skip to content

Refunds and disputes should take access away, exactly once

Which webhook carries a refund or a dispute on each payment provider, how to find the purchase it belongs to, and how to revoke access without a late event giving it back.

Next.js on Vercel3 min readships at docs/solutions/nextjs-vercel/refunds-disputes-and-entitlements.md

Tags: billing · refunds · chargebacks · webhooks · entitlements · stripe · polar · dodo · lemonsqueezy

A lifetime deal that survives its own refund is a free product with extra steps. So is one bought with a stolen card that gets charged back. Access has to follow the money, and the money moves after checkout.

Where each provider tells you

ProviderRefundDisputeHow to find the purchase
Stripecharge.refunded (charge.refunded true, or amount_refunded above 0)charge.dispute.createdthe charge's payment_intent, then the Checkout Session for it
Polarorder.refunded (status refunded or partially_refunded)handled by Polar as merchant of recordthe order id
Dodo Paymentsrefund.succeeded (is_partial)dispute.openedthe payment id
Lemon Squeezyorder_refunded (refunded_amount against total)handled by Lemon Squeezythe order id

Stripe is the one that needs care: refund and dispute events carry a charge, not your order. Store the PaymentIntent id on the purchase at checkout time (and put your metadata on payment_intent_data), then look the session up by it: stripe.checkout.sessions.list({ payment_intent }).

Re-read, then write the whole state

Do not apply "a refund of $10" as a delta. Re-fetch the order or the charge and write what it says now: status, amount refunded, when. Applying the current state is idempotent: the same event twice writes the same row, and a missed event is fixed by the next one or by a nightly reconcile.

Status only moves forward

Rank the statuses and let the store refuse to go backwards:

pending < failed < paid < partially_refunded < disputed < refunded

A redelivered paid that lands after the refund now changes nothing. The one case this gets wrong: a dispute you later win stays disputed. It is rare; fix that row by hand, or add a dispute won event that writes paid through a separate path.

Decide what each status grants

StatusAccess
paidyes
partially_refundedyes: a goodwill partial refund is not a cancellation
disputedno, from the moment the dispute opens
refundedno
pending, failedno

Keep that in one function that every access check calls. Then the pricing table, the gate on a server action and the admin panel agree, because none of them decides on its own.

Subscriptions are different

A refunded subscription invoice does not end the subscription; the subscription's own status does. Let the provider's subscription events drive access there (canceled, unpaid, expired revoke) and treat invoice refunds as bookkeeping.

Test it

  1. Buy the one-time price with a test card; access appears.
  2. Refund it in the provider's dashboard. The webhook marks the purchase refunded and access goes.
  3. Replay the original paid event from the dashboard. The purchase stays refunded and the replay answers as a duplicate.