How to Handle License Expiry in Your Desktop App
Step-by-step implementation of expiry handling: grace periods, user messaging, and fallback behavior for expired desktop app licenses.
License expiry is easy to implement technically but easy to get wrong from a UX perspective. Show the wrong error at the wrong time and you'll get angry support emails from users who just had a payment processing delay.
Here's the complete implementation pattern.
The Three Expiry States
- Active — valid, full access
- Near-expiry — 14 days left, show a non-blocking reminder
- Expired — past expiry, show a blocking screen with a renewal CTA
const result = await validateLicense(key);
if (result.valid) {
const expiresAt = result.expires ? new Date(result.expires) : null;
const daysLeft = expiresAt
? Math.ceil((expiresAt.getTime() - Date.now()) / 86_400_000)
: null;
if (daysLeft !== null && daysLeft <= 14) {
showRenewalReminder(daysLeft); // non-blocking banner
}
launchApp();
} else if (result.status === 'expired') {
showExpiredScreen();
} else {
showBlockedScreen(result.status);
}
Near-Expiry Reminder Copy
Show this as a dismissible banner, not a modal. Don't block the user — they're doing real work. The message is factual and calm, not threatening.
Expired Screen
When truly expired, block access with a clear screen that:
- States that the license has expired (not "invalid key" or cryptic errors)
- Shows the exact expiry date
- Provides a direct renewal link
- Optionally: a "Retry" button for users whose payment just went through
Offline Grace Period
Never hard-block users who can't reach the internet. Always implement a grace period:
const GRACE_DAYS = 7;
const CACHE_KEY = 'kp_license_cache';
async function validateWithGrace(key: string): Promise<ValidationResult> {
try {
const result = await callValidateAPI(key);
if (result.valid) {
localStorage.setItem(CACHE_KEY, JSON.stringify({
...result, cachedAt: Date.now()
}));
}
return result;
} catch {
const cached = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null');
if (!cached) return { valid: false, status: 'network_error' };
const ageDays = (Date.now() - cached.cachedAt) / 86_400_000;
if (ageDays <= GRACE_DAYS) return { ...cached, offline: true };
return { valid: false, status: 'offline_grace_exceeded' };
}
}
Status → UI Mapping
| Status | Show user | Action |
|---|---|---|
active | License is valid | Allow full access |
expired | "License expired on [date]" | Renew link |
revoked | "This license has been disabled" | Contact support |
ip_blocked | "Access restricted from this location" | Contact support |
not_found | "Invalid license key" | Show error |
Testing Expiry Flows
In KeyPort, manually set an expiry date to a past date on a test license. Use this to verify your expired and near-expiry UI before shipping. Expiry UX bugs discovered when a real customer's license expires are the worst possible time to find them.