Let Customers Fix Their Own License Problems
Every manual support step you own is a step your customers can only take while you are awake.
That is the whole problem, and it is worst exactly when things go well. A launch spike, a Product Hunt day, a bundle deal: the same event that produces revenue produces a proportional pile of “I got a new laptop” emails. Support load is not independent of success. It is a tax on it.
The reflex fix is to get faster at replying. Canned responses, a better inbox, a support tool. That helps, and there is a template for exactly that in reducing license activation support tickets. But a faster reply is still a reply. The customer in Melbourne who emails at 9pm their time is blocked until you wake up in Europe, which is a full working day of them staring at an app they paid for and cannot use. No amount of typing speed fixes a timezone.
The actual fix is to remove yourself from the loop for the operations that are safe to automate. This post is about which ones those are, and how to build the thing correctly, because the hard part is not the buttons.
Which operations are safe to hand over
The dividing line is reversibility. If a customer can perform an action, regret it, and undo it themselves, it belongs in the portal. If the action moves money or destroys state, it stays with you.
Safe to self-serve:
- Deactivate a device. The most valuable one by far. A freed seat is immediately reusable, and the customer knows which machine they sold better than you do.
- Resend the license email. Covers lost keys, spam folders, and the customer who bought on a work address and installs on a personal one.
- View remaining seats and the device list. Not an action at all, but it prevents the ticket. Most “am I out of activations” emails are people who cannot see the number.
- Update the billing email. People change jobs. If the address on the license is dead, every downstream email you send is theater.
- Download invoices. Any customer who expenses your app will ask. Every one of them, eventually.
Keep manual:
- Extending a subscription for free. This is a discount decision, not a support action.
- Refunds outside your stated policy. Inside policy, automate if you like. Outside it, that is a judgment call and it should stay one.
- Merging accounts. Someone bought twice under two addresses and wants one license with the combined seats. Merges are irreversible and full of edge cases.
- Anything that deletes history. Purging a customer record on request is real work with legal weight. Do not put it behind a button.
The pattern under those lists: self-serve actions should be idempotent or trivially repeatable. Deactivating a device twice is fine. Merging two accounts twice is a corruption bug.
One more test worth applying. Ask whether you would perform the action without asking a single follow-up question. If the answer is yes, a customer can do it themselves. If your instinct is to check something first, that instinct is the part that does not automate.
Identity is the hard part, and passwords are the wrong answer
The engineering problem in self-serve license management is not the operations. It is proving the person clicking is the person who paid.
Desktop app customers have no password with you. They paid through a checkout, got an email, and pasted a key. There is no account. So the tempting move is to build one: signup, password, reset flow, session store, breach exposure, a support queue for people who forgot the password. For a customer who returns to your portal maybe twice a year, that is a permanent maintenance obligation to serve an interaction that happens rarely.
Do not build accounts with passwords for this. It is over-engineering, and it makes support worse, not better, because you have added a whole new failure mode (“I can’t log in to the thing that fixes my login problem”).
Email-link verification is the correct primitive for almost everyone. The customer’s email address is already the identity of record. It is what the purchase was tied to, what the license was delivered to, and what you would have checked manually anyway. A signed, short-lived token sent to that address proves the same thing a password would, without storing a credential.
import { SignJWT, jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.PORTAL_SECRET!)
// Issued after the customer enters their email. Sent as a link, never shown
// in the browser that requested it. Single use, enforced via jti below.
export async function issuePortalToken(licenseId: string, email: string) {
return new SignJWT({ sub: licenseId, email })
.setProtectedHeader({ alg: 'HS256' })
.setJti(crypto.randomUUID())
.setIssuedAt()
.setExpirationTime('15m')
.sign(secret)
}
export async function redeemPortalToken(token: string) {
const { payload } = await jwtVerify(token, secret)
// Burn the jti. A link forwarded to a colleague, or sitting in a shared
// support inbox, must not grant a second session.
const fresh = await consumeJti(payload.jti as string, payload.exp!)
if (!fresh) throw new Error('token_already_used')
return { licenseId: payload.sub as string, email: payload.email as string }
}
Three details that matter more than the crypto. Make the token single use, so a forwarded link is dead on arrival. Keep the link short lived (fifteen minutes) and the redeemed session longer (a few hours), because the link travels through email and the session does not. And return an identical response whether or not the address matches a customer, so the endpoint cannot be used to test which addresses bought your app.
Rate limiting, because a resend endpoint is an email bomb
An unbounded “email me my license” endpoint is a weapon pointed at your customers and your sending domain. Someone types a victim’s address into your form a thousand times, and your app is now the vector.
The counterintuitive part: rate limit on the destination email address, not on the requester’s IP. The attacker owns the IP and can rotate it freely. The victim owns the address, and the address is the thing being harmed. IP limits are a useful second layer, never the primary one.
Numbers that work in practice: one send per address per five minutes, and a ceiling of five per address per day. Legitimate customers hit neither. The person who did not receive the first email will click again within a minute, so tell them another one is already on its way rather than silently sending a second.
Cap total portal emails per hour across your tenant too. It will not trigger for a normal app, and it is the circuit breaker that saves your sender reputation when something goes wrong at 3am. Deliverability is fragile and slow to repair, which is its own subject worth reading up on before you find out the hard way.
Two more, cheap to add and worth it. Log every issuance attempt with the requested address, whether it matched, and the source IP, so an abuse pattern is visible in a query instead of a hunch. And put a token bucket in front of the deactivate endpoint as well, because “remove every device on this license” repeated in a loop is a denial of service against a paying customer, not just noise.
Link into the portal with context, never dump them on a login page
A customer who is already inside your app has told you everything you need. Making them retype it is a self-inflicted drop-off.
The wrong flow: the app shows an error, the customer finds a support page, clicks a generic portal link, lands on a form asking for the email they used at purchase, guesses wrong, and emails you anyway. Every step is a place to lose them.
The right flow: the app opens a URL that already carries the license identifier and the specific reason the customer is there. The app knows the key it is holding. It knows the activation failed because the device limit was hit. Pass both.
// Called from the app when activation fails with device_limit.
// The license id is public and useless without email verification, so it is
// safe in a URL. The email is a prefill hint, never a proof of identity.
const url = new URL('https://portal.example.com/manage')
url.searchParams.set('license', licenseId)
url.searchParams.set('intent', 'device_limit')
url.searchParams.set('hint', purchaseEmail ?? '')
open(url.toString())
The portal reads intent and opens directly on the device list with the removal action in front of them, rather than a menu. It still sends the verification email, because the query string proves nothing. But the customer’s job is now one click plus one email, and the page they land on is about their actual problem.
Put that link in the app before the customer needs it, in the same place the license status lives. A “Manage License” item that only appears inside an error dialog is a link nobody can find when the app will not launch at all.
Log enough to defend a self-serve action later
Self-serve means actions happen without a human witness, which is fine until someone disputes one. “I never removed that device” and “I never changed the billing email” are conversations you will have. An audit trail is what turns them into a thirty-second lookup.
Record, for every self-serve action: what happened, which license, which verified email, the token id that authorized it, the timestamp, the source IP, and the before and after state. That last pair is the one people skip and later wish they had. Knowing a device was removed is less useful than knowing which device, with its identifier and label, because that is what lets you put it back.
Write the log append only and never let a failed audit write block the customer’s action. If logging is down, the deactivation should still succeed. A customer stuck behind your observability problem is worse than a gap in the trail.
Retain it long enough to outlive your refund and chargeback window. A chargeback can land months after the purchase, and the record of what the customer actually did in that window is the evidence. The mechanics of what happens to a key after a refund are covered in refunds in licensed software.
Build it or adopt it
Both paths are legitimate, and the DIY version is achievable.
Building it yourself is roughly: a token issuance and redemption route, a rate limiter keyed on email, an email sender with a warm domain, a device list view, a deactivate action, an audit table, and the deep-link handling in your app. Call it a week of focused work. It is not hard. It is just permanent, because it lives on the path between your customers and the software they paid for. Every one of those parts has to keep working while you are shipping features, and the failure mode is silent: a portal that is quietly broken produces support tickets, which is exactly what you built it to avoid. That pattern of the simple thing becoming a liability is the subject of why licensing gets hard at scale.
The alternative is a licensing layer that ships the portal as part of the system. Keylight includes a hosted customer portal with email-link verification, device deactivation, key retrieval, and an audit record on every action, because the licensing system already holds the device state and the identity that make those operations possible. Stripe stays your payment processor and keeps owning billing. See pricing for what it costs.
Whichever way you go, the measurable outcome is the same, and it is worth watching. Device reset rate and support tickets per hundred customers both belong on the short list in the licensing metrics worth tracking. If self-serve is working, resets go up and tickets go down at the same time. That divergence is the entire point.
If there is a support pattern you think should be self-serve and is not, send us your feedback.
Frequently asked
Do I need to build a login system for a customer license portal?+
No. Passwords are the wrong primitive for desktop app license management. Send a short-lived signed link to the email on the license and let that link be the session. Customers buy once and return twice a year, so a password is something they will only ever reset.
Which license operations are safe to let customers perform themselves?+
Anything reversible and bounded: deactivating a device, resending the license email, viewing remaining seats, updating the billing email, downloading invoices. Keep anything irreversible or money-moving manual, including refunds outside policy, free extensions, and account merges.
How do I stop a resend endpoint from becoming an email bomb?+
Rate limit on the target email address, not the requester IP, since the attacker controls the IP and the victim owns the address. One send per address per few minutes and a low daily ceiling is enough. Return the same response whether or not the address exists.
How long should a portal magic link stay valid?+
Fifteen minutes for the link itself, with a session of a few hours once redeemed. Make the token single use so a forwarded email or a link sitting in a shared inbox does not grant standing access.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free