Most AI-built paywalls handle exactly one event: checkout.session.completed sets paid = true. Nothing ever sets it back. So when you refund someone, when their bank opens a dispute, or when a subscription ends, the database still says paid and the app keeps the door open.
charge.refunded, charge.dispute.created and customer.subscription.deleted. If none of them appear, nothing revokes access.From Stripe's event list (Stripe API: Types of events):
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 to lost, warning_closed, or won." Use it if you want to give access back after a dispute you won.refund.failed: "Occurs whenever a refund has failed." Stripe says that when this happens "you need to arrange an alternative way to provide your customer with a refund" (Stripe docs: Refund and cancel payments).customer.subscription.deleted is "Sent when a subscription is canceled", either directly or when a subscription with cancel_at_period_end: true "reaches the end of its billing period" (Stripe docs: Cancel subscriptions). Setting cancel_at_period_end only sends customer.subscription.updated; the customer has paid for the rest of the period, so keep access until the deleted event.Add these to the endpoint's event list (Dashboard, or enabled_events on the endpoint), otherwise Stripe never sends them. Stripe's refund docs also say to verify webhook signatures for these events (Stripe docs: Refund events); a forged refund event is harmless, but the same handler grants access.
The refund and dispute events carry a Charge or a Dispute, not your Checkout Session. Both point to the payment: a Charge has payment_intent, "ID of the PaymentIntent associated with this charge", and a Dispute has charge and payment_intent, the "ID of the PaymentIntent that's disputed" (Stripe API: The Charge object, Stripe API: The Dispute object). So when you fulfill the purchase, store the session's payment_intent next to the user (Stripe API: The Checkout Session object). Then revoking is one lookup.
// inside your 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 for you
if (charge.refunded) await revokeByPaymentIntent(charge.payment_intent, 'refunded');
break;
}
case 'charge.dispute.created':
await revokeByPaymentIntent(event.data.object.payment_intent, 'disputed');
break;
case 'customer.subscription.deleted':
await revokeBySubscription(event.data.object.id, 'canceled');
break;
}
return new Response('ok', { status: 200 });
refunded is "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 (source). Pick a rule (for example, revoke only on a full refund) and write it down.warning, such as warning_needs_response (Stripe docs: How disputes work). If you'd rather not cut off a customer who may just not recognize the charge, revoke on needs_response but not on warning_ statuses.charge.dispute.closed arrives with status won, you can restore access./success grants access by itself, a webhook can't take it back for long. Grant from the webhook and have the page only read the database (paid but no access, can someone fake a Stripe payment?).The Dashboard can list your refunded payments (source). To catch disputes too, page through charges with the API and check refunded and disputed, "Whether the charge has been disputed" (source). Revoke each one with the same function the webhook now uses.
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkcharge.refunded, which Stripe says occurs whenever a charge is refunded, including partial refunds. refund.created and refund.updated carry the Refund object itself, and refund.failed fires if the refund fails.
Listen for charge.dispute.created, read payment_intent from the Dispute object, and revoke access for the user you stored with that PaymentIntent when they paid.
No. It sends customer.subscription.updated and the subscription runs until the end of the paid period. Remove access when customer.subscription.deleted arrives.
Yes. Check the Charge: refunded is true only for a full refund, and amount_refunded shows how much was refunded.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-10-27. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.