Paid Upgrades: How to Charge for Version 2
Year two is when the perpetual license bill comes due.
The first year is fine. You sold a one-time license, you shipped updates, people were happy. Then the second year happens. You have shipped fourteen releases, rewritten the sync engine, added the thing everyone asked for, and none of it produced a dollar. Your customer count went up. Your revenue did not.
Most developers hit this with no plan, because the licenses they already sold do not carry the information they now need.
Why year two breaks a perpetual license
A perpetual license makes an open-ended promise and collects a closed-ended payment. The mismatch is structural, not a pricing mistake.
When you sell a license once and support it forever, your revenue depends entirely on new customers arriving faster than your support and development costs grow. For a while that works. Then your addressable market saturates, growth flattens, and you are maintaining a codebase for thousands of people who paid you once in 2024.
The trap has a second jaw. Every free update you ship makes the version those customers already own better, which reduces any reason to ever pay you again. You are competing with your own past releases and losing.
The head-to-head between one-time and subscription models is covered in one-time vs subscription licensing, so I will not redo it here. This post is about the specific mechanism that gets you out of the trap without abandoning one-time purchases.
The three ways to charge for version 2
There are three real options, and two of them cost you more than they earn.
Option one: switch everyone to subscription. This is the wrong answer for most desktop apps, and it is the one people reach for first because it solves the revenue problem in a spreadsheet. What the spreadsheet does not model is that you told people they owned something and now they rent it. The backlash is predictable and loud. Subscriptions are legitimate when you carry a real ongoing cost: you run servers, you sync data, you pay for inference on every request. If your app runs entirely on the customer’s machine, a subscription asks them to pay rent for a house that costs you nothing to keep standing. They notice. The cases where it genuinely fits are in subscription licensing for Mac apps.
Option two: ship version 2 as a separate product. New listing, new license, new price, existing customers buy fresh. This works commercially and fragments everything else. You now maintain two license schemas, two support surfaces, two sets of docs, and two builds people confuse. Your support inbox fills with “I bought this, why does my key not work.” Technically the easiest path, operationally the worst one.
Option three: version-window licensing. The purchase covers every update released inside a window, typically twelve months. After the window closes, the app keeps running forever on the last version covered. Nothing is removed, nothing expires, nothing stops working. The customer decides whether to buy another window when they want the newer releases.
Option three is the one worth building.
Version-window licensing is the best default for desktop apps
Version-window licensing is the right default for any app that does not have a genuine ongoing service cost, because it charges for the thing that actually costs you money and takes nothing away when the customer stops paying.
Think about what you are really selling. Not access to a binary. Copying a binary costs you zero. You are selling continued development: the bug fixes, the OS compatibility work, the features. That work has a per-year cost. A window prices exactly that.
The customer side reads as fair because it is fair. They paid, they got a year of improvements, the app they installed still opens tomorrow and in five years. If they skip a renewal and come back later, they buy a new window and jump to current. No lockout, no “your license expired” dialog blocking a document they wrote.
Three details decide whether this works in practice.
Anchor on release date, not install date. Coverage is determined by when a build shipped, not when the customer downloaded it. Otherwise a customer who installs a two-year-old release today gets a fresh year of nothing.
Never revoke a covered version. The moment your app can stop working because a window closed, you have built a subscription with extra steps and you will get the subscription backlash without the subscription revenue.
Keep renewal optional and unnagged. One clear notice when the window ends, not a banner on every launch. A customer sitting happily on version 2.4 for three years is not a failure, they are someone who might buy version 4.
Sketch and JetBrains both run variants of this and both keep large, non-furious user bases. Not an accident.
Pricing the upgrade and where to draw the grandfathering line
Charge existing customers 40% to 60% of the new-customer price, and give version 2 free to anyone who bought within the last 6 to 12 months.
On the discount, the range matters more than the exact number. Under 30% off does not read as recognition, it reads as a coupon, and customers who have been with you two years feel it. Over 70% off tells every new buyer they are subsidizing the loyal ones, which is a bad message to put on your own pricing page. Somewhere near half is the honest middle: enough that returning is obviously the cheaper path, not so much that new purchases look overpriced. If you want the wider context on where to set the base number, Mac app pricing in 2026 covers it.
Grandfathering is where developers try to be clever and lose money doing it. The instinct is to make the free window narrow, maybe 60 days, because every free upgrade is revenue you did not collect. Run the arithmetic. Someone who paid full price five months ago and gets asked to pay again does one of three things: requests a refund, writes a support email you answer personally, or posts about it. Each costs real money or real reputation, and the revenue you were protecting was a fraction of your base.
Be generous and be explicit about the cutoff. “Bought on or after January 1? Version 2 is free, your key already works.” That sentence prevents more tickets than any FAQ page. Handling those tickets well is a whole discipline of its own, covered in the customer portal work and in the licensing metrics you should be watching before and after a launch like this.
One more rule: never make an existing customer re-enter a key to get their free upgrade. If your grandfathering requires manual action from the customer, a meaningful percentage will never take it and will believe you broke their license.
The license field you will regret not adding
Your license must carry a version entitlement, not a boolean. This is the technical decision that makes every option above possible, and it costs almost nothing to add before you ship version 1.
Most homegrown license systems store something close to { key, email, valid: true }. That shape can answer exactly one question: is this person a customer. It cannot answer the question paid upgrades depend on, which is what is this person a customer of.
You need one of two fields inside the signed license itself:
maxVersion, the highest app version this license covers, orcoverageEndsAt, a date, with every build tagged with its release date.
The date form is better. It handles version numbering changes, it does not care whether you ship 2.0 or 1.9.4, and it lets you extend coverage without reissuing a version string. Whichever you pick, it has to live inside the signed payload, not in a database column your app has to phone home to read. Signed payloads are covered in the Ed25519 lease format.
Here is the check, resolving entirely offline:
type License = {
key: string
coverageEndsAt: string // ISO date, inside the signed payload
lastCoveredVersion: string // for the upgrade screen
signature: string
}
type Build = {
version: string
releasedAt: string // ISO date, baked in at compile time
}
function isBuildCovered(license: License, build: Build): boolean {
// Anchor on when the BUILD shipped, never on today's date.
// A customer whose window closed in March can still run any
// release that shipped before March, forever, with no network.
return Date.parse(build.releasedAt) <= Date.parse(license.coverageEndsAt)
}
// At launch, offline, no server call:
const license = await readCachedLicense() // signed blob on disk
if (!verifySignature(license, PUBLIC_KEY)) {
return startTrialOrPrompt()
}
if (isBuildCovered(license, CURRENT_BUILD)) {
return runFullApp()
}
// Covered window closed and the user updated past it.
// Do NOT disable the app. Offer the two honest choices.
return showUpgradeScreen({
renewPrice: 39,
lastCoveredVersion: license.lastCoveredVersion,
})
Note what is not in that code. No network call, no expiry check against wall-clock time, no path that turns the app off. Comparing two dates already inside the signed payload works on a plane and works if your server is down. The principle is in how offline license validation works.
The last branch matters more than it looks. A customer whose window closed and who then auto-updates into an uncovered build should not be locked out. Let them keep using it, or offer a one-click roll back to their last covered version, and make the upgrade offer visible without making it modal. Keeping the license itself intact across those updates is a separate problem, covered in persisting licenses across updates.
Retrofitting all of this into a system with only a boolean is the painful path. Every license you already sold has no version data, so you backfill entitlements from purchase records, you ship an app update that understands both the old shape and the new one, and you carry that compatibility branch for years because some customer will install a 2024 build in 2029. Doing it in the first schema is a single extra field.
Adding the field does not commit you to charging for upgrades. It just means you can decide later. That optionality is free on day one and it is expensive to buy back.
How to announce an upgrade so it reads as fair
The announcement decides how the change lands, and the load-bearing part is telling people what does not change before you tell them what costs money.
Lead with the guarantee. “Version 1 keeps working. Forever. No expiry, no lockout.” Then the cutoff, in one sentence with a specific date. Then the upgrade price next to the new-customer price, so the discount is visible rather than claimed. Then what version 2 actually does, which is the part you wanted to lead with and should not.
Do not use the word “expired” anywhere. Nothing expired. A window closed and the customer keeps everything they had. If your renewal email says “your license has expired,” you have contradicted your own model in the subject line.
Send it twice: once a month before, once on release day. Then stop. A customer who does not want version 2 is not a lead to nurture.
You can build all of this yourself. It is a version field in the license payload, a signing step, a date comparison at launch, and an upgrade checkout flow that knows who is eligible. None of it is hard. Maybe a week of work plus ongoing maintenance of a key server, and the part that bites later is not the signing, it is the eligibility logic and the reissue path. This is the layer Keylight handles, with version entitlements carried in the signed license and license keys that already know what they cover. Stripe stays your payment processor for both the original purchase and the upgrade.
Either way, add the field. Shipping a perpetual license with no version entitlement encoded in it is a decision you will regret in year two, and it costs you one line in a schema today.
Frequently asked
What is version-window licensing?+
A purchase entitles the customer to every update released inside a defined window, usually twelve months. When the window closes the app keeps working forever on the last version it covered. The customer chooses whether to buy another window to keep receiving updates.
How much should I discount a paid upgrade for existing customers?+
Between 40% and 60% off the new-customer price is the common range for desktop apps. Below 30% reads as a rounding error and does not feel like recognition. Above 70% and you have effectively told new buyers they overpaid.
Who should get version 2 for free?+
Anyone who bought inside a cutoff window before the release, commonly the last 6 to 12 months. Recent purchasers who get billed again will ask for refunds and say so publicly, and that costs more than the revenue you collected from them.
Can I add version entitlements to a license system that only stores valid true or false?+
Yes, but it is painful. Every license issued before the change has no version data, so you have to backfill from purchase dates and ship an app update that understands both the old and new shapes. Adding the field on day one costs almost nothing.
Do paid upgrades work offline?+
They have to. The entitlement needs to be carried inside the signed license the app already holds, as a max version or a coverage end date, so the app can compare the running build against it with no network call.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free