Guides · Free check · $9 fix pack

Guides

Refunded, disputed or canceled, but the customer still has access

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.

How to tell

  1. Open your webhook endpoint in the Stripe Dashboard and look at which events it's subscribed to. If the list is only checkout or payment-success events, Stripe never tells your app about refunds or disputes, so the app can't react.
  2. Refund a test-mode purchase, then reload the app as that user. If the paid content is still there, access is one-way.
  3. Search your handler for charge.refunded, charge.dispute.created and customer.subscription.deleted. If none of them appear, nothing revokes access.

The events that should take access away

From Stripe's event list (Stripe API: Types of events):

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.

Map the event back to the user

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 });

Decide what partial refunds and inquiries mean

Things that make revocation fail quietly

Clean up the customers who kept access

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.

Check yours in 10 seconds (free).

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 check

FAQ

Which Stripe webhook event fires when I refund a payment?

charge.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.

How do I remove access when a customer opens a Stripe dispute?

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.

Does cancel_at_period_end remove access right away?

No. It sends customer.subscription.updated and the subscription runs until the end of the paid period. Remove access when customer.subscription.deleted arrives.

Is charge.refunded sent for partial refunds?

Yes. Check the Charge: refunded is true only for a full refund, and amount_refunded shows how much was refunded.

Sources

More guides

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.