How to Revoke a Software License After a Failed Payment
If you sell subscription licenses, one question comes up the moment you go live: what actually happens to a customer's access when their card gets declined? Here's the real, honest answer for how PermitCore's Store handles it — including the part where a single failed charge does not immediately cut anyone off.
A failed charge isn't an instant revoke
When a recurring payment fails, Stripe's own subscription retry logic (Smart Retries) kicks in first — it retries the charge over several days on its own schedule, not PermitCore's. A single payment_intent.payment_failed event just marks that one payment attempt as failed; it doesn't touch the license at all. This matters because cards decline for all kinds of temporary reasons — an expired card the customer hasn't updated yet, a bank's fraud flag, an over-limit charge — and revoking access on the very first failure would punish a lot of customers who'll pay successfully a day or two later.
What actually triggers a revoke
The license is only revoked once Stripe gives up and actually cancels the subscription — the customer.subscription.deleted event. This is the real handler, unmodified:
private async Task HandleSubscriptionDeleted(Event ev, Guid tenantId) { var subscription = ev.Data.Object as Subscription; var orders = await _context.Orders .Include(o => o.LicenseKey) .Where(o => o.StripeSubscriptionId == subscription.Id && o.TenantId == tenantId && o.Status == OrderStatus.Completed && o.LicenseKeyId != null) .ToListAsync(); foreach (var order in orders) { var license = order.LicenseKey; license.IsActive = false; license.UpdatedAt = DateTime.UtcNow; _audit.Log("RevokeLicense", null, tenantId, "LicenseKey", license.Id.ToString(), "stripe-webhook", "Stripe", $"{{\"reason\":\"store_subscription_cancelled\",...}}"); await _webhooks.EnqueueAsync(tenantId, "license.revoked", new { licenseId = license.Id, reason = "subscription_cancelled" }); } await _context.SaveChangesAsync(); }
This fires whether the customer cancels voluntarily from their billing portal or Stripe's own retry sequence eventually exhausts — either way, PermitCore reacts to the same event, sets IsActive = false (the identical soft-revoke a vendor's own manual "Revoke License" button uses), writes an audit log entry with the specific reason, and fires a license.revoked webhook so your own backend can react — send a notification, downgrade a related account, whatever your product needs.
Refunds work the same way
A full refund revokes the license the same way — same IsActive = false, same audit trail, reason store_order_refunded instead. A partial refund is deliberately different: it's recorded (the refunded amount is tracked on the order) but doesn't touch the license at all, since a partial/goodwill refund isn't the same signal as "this customer should lose access."
What this means for your own app
Your app should still call validate() periodically rather than trusting a single activation forever — IsActive = false is what makes a revoked license actually stop passing validation, not a separate flag you need to check. If you want to react immediately rather than waiting for the customer's next validation call, subscribe to the license.revoked webhook and act on it server-side — invalidate a cached session, send your own "your subscription ended" email, whatever fits your product.