Dunning for Desktop Apps: When a Card Fails, Don't Lock the App
A meaningful share of your subscription cancellations were never decisions. They were expired cards.
The customer did not weigh your app against an alternative and pick the alternative. Their bank reissued the card after a fraud flag, or they switched banks, or the charge hit two days before payday and the issuer said no. They still want the product. They just do not know anything went wrong, because the notice went to the same email address that already eats your license mail.
This is involuntary churn, and it is the cheapest revenue you will ever recover. It is also the thing most small apps handle by accident, usually by wiring a subscription.updated webhook straight to a revoke call and never thinking about it again.
Voluntary and involuntary churn are different problems
Voluntary churn is a person choosing to leave. Involuntary churn is a machine saying no. Treating them as one number is why people build the wrong fix.
When you see cancellations climbing and you have not separated the two, the instinct is to work on the product. Better onboarding, more features, a win-back discount. All of that is aimed at people who made a decision. If half of your churn is failed cards, none of it lands, because the customer you are trying to win back never went anywhere.
Split the number first. Every payment processor tells you why a subscription ended. Tag them, then look at the ratio. If involuntary churn is any real fraction of the total, that fraction is recoverable at close to zero cost, and it is worth more attention than another retention email.
The mindset difference matters as much as the metric. When someone chooses to leave, respect it. Make cancellation easy, do not put a maze in front of it, do not guilt them. When a card fails, the opposite applies. That customer has not left. Your job is to make sure they find out, and to keep the door open while they fix it. Lapse recovery rate is one of the nine numbers worth watching in the licensing metrics every app business should track, and it only means something once you have separated these two causes.
Immediate retries are mostly wasted attempts
Retrying a declined card an hour later almost never works, because the reason it declined an hour ago is still true an hour later.
Decline reasons cluster into a few buckets. Expired cards stay expired until the customer types in a new one. Cards reported lost or stolen stay dead permanently. Insufficient funds is the interesting case, and it is the only one where waiting genuinely helps, because balances change on paydays and deposit cycles. That is a weekly or biweekly rhythm, not an hourly one.
So the schedule that works is spread out, not aggressive. Something in the shape of day 1, day 3, day 5, day 7, then a final attempt around day 14 covers both a mid-month and an end-of-month paycheck. Four or five attempts across two to three weeks. Hammering the card six times in the first 48 hours accomplishes nothing except raising your decline ratio with the card networks, which is a metric you would rather keep clean.
You mostly do not have to build this. Stripe runs Smart Retries on its own schedule and you configure it in the dashboard. That is the correct division of labor and you should not be reinventing it. Stripe owns the card and the retries. What Stripe cannot decide for you is what the customer sees inside your app while those retries are running, because Stripe does not know what your app is or what a degraded state looks like in it. That part is yours, and it is the part that determines whether the recovery actually converts.
What your app should do during the retry window
Keep full access, tell the customer inside the app, and change nothing else until the retries are exhausted.
That is the whole policy. The implementation is a state on the license, not a deletion of it. When the payment-failed webhook arrives, move the license to a grace state with a deadline attached, and let your validation path treat grace as valid.
// Payment failed. Move to grace, do not revoke.
// The deadline is derived from the processor's own retry
// schedule so the app never locks before recovery is over.
const RETRY_WINDOW_DAYS = 14
const BUFFER_DAYS = 3 // absorbs lease-refresh lag on offline machines
export async function onInvoicePaymentFailed(event: PaymentFailedEvent) {
const license = await licenses.findBySubscription(event.subscriptionId)
if (!license || license.state === 'grace') return // idempotent
const graceUntil = new Date(
event.occurredAt.getTime() +
(RETRY_WINDOW_DAYS + BUFFER_DAYS) * 86_400_000,
)
await licenses.update(license.id, {
state: 'grace',
graceUntil,
billingIssue: 'payment_failed',
})
}
// Validation treats grace as a pass, with a flag the app can render.
export function resolveEntitlement(license: License, now: Date) {
if (license.state === 'active') return { valid: true, notice: null }
if (license.state === 'grace' && now < license.graceUntil) {
return { valid: true, notice: 'billing_issue' }
}
return { valid: false, notice: 'lapsed' }
}
Two details in there carry most of the weight. The handler is idempotent, because a two-week retry schedule means several failure events for one billing problem and you do not want the deadline sliding forward on every one of them. And the grace deadline is computed from the retry window rather than picked out of the air, so access never ends while a charge might still succeed.
The notice flag is what your UI reads. A single field is enough. Everything the customer sees hangs off it.
Hard-locking on the first failed charge is the wrong default
Locking the app the moment a charge fails is self-defeating, and it costs more than it protects.
Think about what you have actually done. You took a billing event with a good chance of resolving itself inside two weeks and converted it into a support ticket. Now someone has to answer that ticket. Worse, you picked the single worst moment to ask the customer for a favor, because the favor you need is that they go find their new card and type it in.
The reaction to being locked out over a card you did not know had expired is not “let me update my payment method.” It is anger. The customer opens the app to do work, the work is gone, and the app is asking for money. Whatever goodwill you had is spent, and the update-card link is now competing with the thought that maybe they will just find something else. You turned a recoverable lapse into a real cancellation, and you did it to protect maybe two weeks of revenue from a customer who was going to pay you anyway.
There is also a fairness point. During the retry window nobody has decided anything. The customer has not said no and your processor has not given up. Removing access is you deciding on their behalf, based on incomplete information, in a way that damages them. The subscription model already asks for ongoing trust from someone who installed software on their own machine, and unlike a web app you are not holding their data hostage on your servers. Behave accordingly.
Notify in the app, not only in email
The app is the one channel you control, and it is the only one you can be sure reaches the person who is actually using the software.
Email fails here in specific ways. It goes to the address on file, which is often not the address the person reads, especially on team purchases where the card belongs to whoever handled procurement. It lands in Promotions. It gets filtered by the same rules that already send your license keys to spam. And a billing notice looks exactly like the phishing everyone has been trained to ignore. If email is your only channel for this, a real portion of your recoverable churn never learns there is anything to recover.
The in-app notice does not have that problem. The person seeing it is holding the product. Put it where they will see it without being blocked by it: a bar at the top of the window or a badge in settings, dismissible, persistent across launches, with one button that opens the payment update page in a browser.
Wording matters more than placement. The failure mode is sounding like a collections notice.
Your payment did not go through. Your subscription is still active while we retry. Update your card to avoid interruption. [Update payment method]
That works because it states the fact, tells them nothing is broken yet, and gives them one thing to do. What does not work is any version with a countdown clock, a warning icon, or the word “immediately.” Do not threaten. The customer has done nothing wrong, and a card expiring is not a moral event. Send them somewhere they can fix it in one step, which is what a hosted customer portal is for. The general principle of pushing fixes into a self-serve path instead of your inbox is covered in reducing license activation support tickets.
The offline machine problem, and when to stop
A lapsed-payment state only reaches a machine when that machine next refreshes its lease, so your grace window has to account for the lag.
This is the part that is genuinely different on desktop. A web app changes state on the server and the next request sees it. Your app holds a signed lease that stays valid without a network, by design, because that is what makes it work on a plane. If your lease lifetime is seven days, a customer whose card failed on Monday may not learn about it until the following Monday. The mechanics are in how offline license validation works.
Two consequences. First, your grace deadline should be the retry window plus at least one full lease interval, which is the buffer in the code above. Second, do not shorten your lease lifetime to make dunning faster. That trades your offline story for a billing edge case, and offline reliability is a much bigger promise to your customers than two extra days of recovery signal.
Then there is the end. The window cannot be infinite. When the processor’s final retry fails and the notices have gone unanswered, recovery is over and you are into a different question: what your app does for someone who has definitively stopped paying. That is a product decision, not a billing one, and it deserves its own answer rather than a lock screen bolted onto the end of this flow. The choice between subscription and perpetual models shapes it too, which is its own subject in one-time versus subscription licensing.
You can build all of this yourself. It is a webhook handler, a state column, a deadline, and a flag your app reads. The work is not the hard part. The hard part is that it is invisible, so it never gets built until you notice the revenue leaking, and by then you have lost a year of recoverable customers. If you would rather not own the state machine, a licensing layer handles it: Keylight keeps grace as a first-class license state, tied to your Stripe integration, with the deadline propagating on the next lease refresh. Pricing is on the pricing page.
Either way, start with the default. Do not lock on the first failed charge. Tell them in the app, keep them working, and let the retries do their job.
Frequently asked
What is dunning in software subscriptions?+
Dunning is the process of recovering a failed subscription payment: the retry schedule your payment processor runs against the card, plus the notifications that ask the customer to update it. For desktop apps it also includes what your app shows while that recovery is in progress.
How long should the grace period be after a failed payment?+
Match it to your retry schedule plus a small buffer, which usually lands between 14 and 21 days. If Stripe stops retrying on day 15, ending access on day 14 throws away an attempt that might still have worked.
Should I lock the app immediately when a payment fails?+
No. The first failed charge is usually an expired card or a routine issuer decline, not a decision to cancel. Locking on day one converts a recoverable billing event into a support ticket and often a real cancellation.
How does a failed payment reach an app that is offline?+
It does not, until the app next refreshes its lease. A machine holding a seven-day lease can stay fully functional for seven days after the billing failure. Plan the grace window around that lag instead of assuming instant propagation.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free