Launch-readiness teardown

A Refund Should Turn Off Access. Does Yours?

Your prototype works. That is not the same as safe to sell.

You wired the happy path: a payment succeeds, the webhook fires, and your handler grants access. You tested it by buying your own product, and access turned on. Ship it?

The money path has a return leg, and it is the one AI-built apps almost always leave out. Payments get refunded. Subscriptions get cancelled and expire. Cards get disputed. When money flows back out, the access it paid for has to flow back too. If your handler only knows how to grant, a customer can get their money back and keep the product forever.

The launch question is concrete:

After a full refund or a cancellation, is the access that purchase granted actually gone?

Only listening for the grant event

The failure has a simple shape: the handler subscribes to the “you got paid” event and nothing else.

// vulnerable: grants on purchase, never revokes
if (event.name === "order_created") {
  await grantAccess(userId);
}
// order_refunded? subscription_cancelled? subscription_expired? — not handled

Lemon Squeezy (and every provider) sends the outbound events too — order_refunded, subscription_cancelled, subscription_expired, subscription_payment_refunded. Ignore them all and nothing ever turns access off. But they do not all mean “revoke now,” and treating them as identical is its own bug — the difference between subscription_cancelled and subscription_expired is where most handlers go wrong (more below). The refund goes through on the provider's side, the customer's money comes back, and your entitlements table still says they are a paying user.

It hides because you test the purchase, not the refund. The demo never issues a refund, so the gap never shows — until a real customer buys, uses it, refunds, and quietly keeps access. Multiply that by anyone who notices it works.

Grant and revoke are the same switch

The fix is to treat entitlement as state your webhook keeps in sync with the provider, in both directions. (event below is a normalized shape — in a Lemon Squeezy webhook the name arrives as meta.event_name and the X-Event-Name header, not a top-level field.)

// fixed: the outbound events revoke what the inbound events granted
switch (eventName) {
  case "order_created":
  case "subscription_payment_success":
    await setAccess(userId, true, eventKey);
    break;
  case "order_refunded":
  case "subscription_expired":
    await setAccess(userId, false, eventKey);
    break;
  // subscription_cancelled is NOT here on purpose — see below.
}

Cancelled is not expired. This is the trap that bites hardest. In Lemon Squeezy a subscription_cancelled event means the customer turned off auto-renew — they have already paid for the current period and keep access until it ends. The docs are explicit: customers should retain access in every status except expired. Revoke on subscription_cancelled and you cut off time your customer paid for — a refund magnet and a support fire. Revoke on subscription_expired (and on refunds); treat subscription_cancelled as “let it run to ends_at.” A single-payment refund (subscription_payment_refunded) is its own decision, closer to a partial refund than a termination — handle it deliberately, not by reflex.

Two more details decide whether this holds, and both are easy to miss.

Events can arrive out of order. Providers do not guarantee delivery order, and retries can re-deliver an old event late. If your logic is “apply whatever this event says,” a delayed order_created that lands after the refund can flip access back on for someone who was refunded. Revocation has to win. The robust fix is to re-fetch the current status from the provider and set access from that authoritative state, rather than blindly applying each event as it lands; comparing event timestamps is a weaker fallback when you can't query.

Revocation must be idempotent too. The same refund event can be delivered more than once (that is the idempotency check, applied to the return leg). Turning access off twice is harmless; but if “revoke” also, say, issues a store credit or sends a “sorry to see you go” email, doing that five times is its own bug. Key the side effects on the event, not just the entitlement.

Run the test against your own app

You do not need a real card to test this. Use test mode:

  1. In test mode, complete a purchase and confirm access turns on.
  2. From the provider dashboard, refund that test order (or cancel + expire the test subscription).
  3. Confirm the entitlement is now off — the user can no longer reach the paid feature, through the UI and through the API.
  4. Using the provider's event tooling — Lemon Squeezy's Simulate Webhook Events, or Stripe's event resend — fire an order_created event once more after the refund. Confirm access stays off — a late grant must not resurrect it. (You can't literally re-POST the original signed request on LS; the simulate tool sends a fresh one.)
  5. List every event that grants access in your handler. For each, confirm there is a matching event that revokes it. A grant with no revoke is the hole.

Assert the entitlement state after each step, not just the HTTP response. A handler can 200 every webhook and still never have turned anything off.

What a passing test proves — and does not

A green refund-revokes test is evidence for the specific events you exercised — a full refund, an expiry, a cancellation that runs to expiry — on the paths you called. It is not a security certification, and it is not your whole billing system.

It does not automatically cover partial refunds (which may or may not warrant revocation — that is a product decision), disputes and chargebacks (which arrive through a different flow and on the provider's timeline, not yours), subscription grace periods and dunning, proration, or team/seat downgrades where one member loses access but the account does not. Those are their own launch checks.

The goal is not to call the billing “correct.” It is to hold one falsifiable line:

Access granted by a purchase is removed when that purchase is fully refunded or its subscription expires — and a late or replayed grant cannot silently restore it.

Keep the test in your launch suite and add a case whenever you add a way to pay — or a way to stop paying. Your prototype turning access on is half the money path. Turning it off when the money goes back is the half that keeps you from giving the product away for free.

See the same red/green testing pattern in the reference repo

Run the broader launch-readiness checklist

Sources: Lemon Squeezy webhook events and Stripe subscription lifecycle.

Test this on your own app before you sell it.