A lot of paywalls handle exactly one Stripe event. checkout.session.completed arrives, the handler sets paid = true, and that's the end of it. Nothing in the code ever sets it back.
So a refund goes out, or a customer's bank opens a dispute, or a subscription ends, and the database still says paid. The app keeps the door open for someone who's no longer paying. This post covers the events that should take access away, how to tie them back to the right user, and the edge cases worth deciding on up front. Every claim links to the doc it comes from (see Sources at the end).
How to tell if access is one-way
- Open the webhook endpoint in the Stripe Dashboard and look at the events it's subscribed to. If the list is only checkout or payment-success events, Stripe never tells the app about refunds or disputes.
- Refund a test-mode purchase, then reload the app as that test user. If the paid content is still there, access only ever goes one direction.
- Search the handler for
charge.refunded,charge.dispute.createdandcustomer.subscription.deleted. If none of them appear, nothing revokes access.
The events that should take access away
From Stripe's list of event types:
-
charge.refunded: "Occurs whenever a charge is refunded, including partial refunds." -
charge.dispute.created: "Occurs whenever a customer disputes a charge with their bank." -
charge.dispute.closed: "Occurs when a dispute is closed and the dispute status changes tolost,warning_closed, orwon." This is the one to use for giving access back after a dispute is won. -
refund.failed: "Occurs whenever a refund has failed." Stripe's refund docs say that in that case "you need to arrange an alternative way to provide your customer with a refund."
For subscriptions, Stripe's cancellation docs describe customer.subscription.deleted as "Sent when a subscription is canceled. The cancellation can result from a direct call to delete the subscription or when a subscription with cancel_at_period_end: true reaches the end of its billing period."
Setting cancel_at_period_end doesn't end anything yet. The same docs say customer.subscription.updated is "Sent for any subscription update, including when cancel_at_period_end is set to true." The customer has paid through the end of the period, so keep access until the deleted event arrives.
Each of these has to be added to the endpoint's event list, in the Dashboard or through enabled_events on the endpoint. Stripe only sends the events an endpoint is subscribed to. Stripe's refund docs also recommend that you "verify webhook signatures" for these events, which matters because the same handler that revokes access also grants it.
Map the event back to the user
Refund and dispute events carry a Charge or a Dispute, not the Checkout Session that granted access. Both point back to the payment:
- On the Charge object,
payment_intentis the "ID of the PaymentIntent associated with this charge." - On the Dispute object,
payment_intentis the "ID of the PaymentIntent that’s disputed," andchargeis the "ID of the charge that’s disputed."
The Checkout Session object has a payment_intent field too. So at fulfillment time, store the session's payment_intent next to the user. Revoking then becomes a single lookup.
// Inside a verified webhook handler (signature already checked on the raw body) switch (event.type) { case 'checkout.session.completed': { const s = event.data.object; await grantAccess(s.client_reference_id, { paymentIntent: s.payment_intent }); break; } case 'charge.refunded': { const charge = event.data.object; // refunded is true only for a full refund; decide what a partial refund means if (charge.refunded) await revokeByPaymentIntent(charge.payment_intent, 'refunded'); break; } case 'charge.dispute.created': await revokeByPaymentIntent(event.data.object.payment_intent, 'disputed'); break; case 'charge.dispute.closed': if (event.data.object.status === 'won') { await restoreByPaymentIntent(event.data.object.payment_intent); } break; case 'customer.subscription.deleted': await revokeBySubscription(event.data.object.id, 'canceled'); break; } return new Response('ok', { status: 200 }); Decide the edge cases before they happen
Partial refunds. charge.refunded fires for those too. On the Charge, refunded means "Whether the charge has been fully refunded. If the charge is only partially refunded, this attribute will still be false," and amount_refunded holds the refunded amount. Pick a rule, such as revoking only on a full refund, and write it down.
Inquiries. Stripe's dispute docs explain that "Some card networks initiate a preliminary phase before creating a formal dispute," which Stripe calls an inquiry, and that in the API an inquiry's status "is prepended with warning," as in warning_needs_response. The same page notes the bank often asks "because the cardholder doesn’t recognize the transaction description." To avoid cutting off a customer over a confusing statement line, revoke on formal dispute statuses and leave warning_ statuses alone.
Disputes that are won. When charge.dispute.closed arrives with status won, restore access, as in the switch above.
Things that make revocation fail quietly
- Access granted by the success page. If
/successunlocks access on its own, a webhook can't keep it revoked. Grant from the webhook and have the page only read the database. - Duplicate deliveries. The same event can arrive more than once, so revoke and restore should be safe to run twice.
- An endpoint Stripe can't reach. Then neither grants nor revokes arrive at all.
Clean up the customers who already kept access
Stripe's refund docs point to a Dashboard view of refunded payments. To catch disputes as well, page through charges with the API and check refunded and disputed ("Whether the charge has been disputed") on each one. Revoke each match with the same function the webhook now calls.
Checklist
- [ ] The endpoint subscribes to
charge.refunded,charge.dispute.created,charge.dispute.closedandcustomer.subscription.deleted - [ ] Fulfillment stores the Checkout Session's
payment_intentwith the user - [ ] Revoke runs only for a full refund, or a written rule covers partial ones
- [ ]
warning_inquiry statuses are handled on purpose - [ ]
cancel_at_period_endkeeps access untilcustomer.subscription.deleted - [ ] A won dispute restores access
- [ ] Past refunds and disputes have been cleaned up
None of this helps if Stripe can't reach the endpoint in the first place. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide, with the revoke logic and a cleanup approach, is here: Refunded or disputed Stripe payment still has access?
WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-11-04.
Sources
- Stripe API, Types of events (
charge.refunded,charge.dispute.created,charge.dispute.closed,refund.failed): https://docs.stripe.com/api/events/types - Stripe, Refund and cancel payments (alternative refund when
refund.failed, verify webhook signatures, Dashboard view of refunded payments): https://docs.stripe.com/refunds - Stripe, Cancel subscriptions (
customer.subscription.deleted,customer.subscription.updatedwhencancel_at_period_endis set): https://docs.stripe.com/billing/subscriptions/cancel - Stripe API, The Charge object (
payment_intent,refunded,amount_refunded,disputed): https://docs.stripe.com/api/charges/object - Stripe API, The Dispute object (
payment_intent,charge): https://docs.stripe.com/api/disputes/object - Stripe API, The Checkout Session object (
payment_intent): https://docs.stripe.com/api/checkout/sessions/object - Stripe, How disputes work (inquiries,
warning_statuses): https://docs.stripe.com/disputes/how-disputes-work


