Skip to main content
Good licensing UX is invisible when things work and helpful when they don’t. These patterns ensure your customers have a smooth experience.

First-run experience

The first time a user launches your app, they need to enter a license key. Make this frictionless:

Desktop apps

Show a clean, focused dialog. Don’t overwhelm with options — just a single input field and an activate button.
┌──────────────────────────────────────────┐
│..........................................│
│...........Welcome to YourApp.............│
│..........................................│
│...Enter your license key to get started..│
│..........................................│
│...┌──────────────────────────────────┐   │
│...│..XXXX-XXXX-XXXX-XXXX.............│...│
│...└──────────────────────────────────┘   │
│..........................................│
│....[ Purchase ]..........[ Activate ]....│
│..........................................│
└──────────────────────────────────────────┘
  • Auto-format the input as the user types (add dashes automatically).
  • Accept keys with or without dashes.
  • Show a “Purchase” link for users who don’t have a key yet.
  • Trim whitespace — users often copy keys with trailing spaces.

CLI tools

Support multiple input methods to minimize friction:
# Via flag
yourapp --license-key XXXX-XXXX-XXXX-XXXX

# Via environment variable
export AUTHFORGE_LICENSE_KEY=XXXX-XXXX-XXXX-XXXX
yourapp

# Interactive prompt (if no key found)
yourapp
> No license key found. Enter your key: _

Storing the key locally

After successful validation, save the key locally so users don’t re-enter it on every launch.
import json
import os

CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".yourapp")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")

def save_license_key(key):
    os.makedirs(CONFIG_DIR, exist_ok=True)
    config = load_config()
    config["licenseKey"] = key
    with open(CONFIG_FILE, "w") as f:
        json.dump(config, f)

def load_license_key():
    config = load_config()
    return config.get("licenseKey")
On subsequent launches, load the stored key and authenticate silently:
stored_key = load_license_key()
if stored_key and client.login(stored_key):
    # Authenticated silently — go straight to the app
    run_app()
else:
    # Show the license key dialog
    key = show_license_dialog()
    if client.login(key):
        save_license_key(key)
        run_app()

Settings page

Provide a settings or “About” page where users can see their license status:
FieldValue
License keyA3K9-****-****-QHDT (partially masked)
StatusActive
PlanPro
ExpiresDecember 31, 2026
Devices1 of 2 slots used
[Deactivate]
  • Mask the license key (show first and last groups only).
  • Show the expiry date prominently.
  • Include a “Deactivate” button that clears the stored key and returns to the license dialog.
  • If using license variables, show the plan tier.

Expiration and renewal

When a license is approaching expiration, show gentle reminders:
from datetime import datetime, timedelta

if client.login(license_key):
    expires_at = client.license_variables.get("expiresAt")
    if expires_at:
        expiry = datetime.fromisoformat(expires_at)
        days_left = (expiry - datetime.now()).days

        if days_left <= 7:
            show_banner(
                f"Your license expires in {days_left} days. "
                "Renew now to avoid interruption.",
                action_url="https://yoursite.com/renew"
            )
When the license has expired, show a clear message with a path to renewal:
┌──────────────────────────────────────────┐
│..........................................│
│.........Your license has expired.........│
│..........................................│
│...Your license expired on Dec 31, 2025...│
│...Renew to continue using YourApp........│
│..........................................│
│....[ Enter New Key ]........[ Renew ]....│
│..........................................│
└──────────────────────────────────────────┘

Trial mode

Use the Developer API to issue time-limited trial licenses:
// Create a 14-day trial license
const trial = await fetch("https://api.authforge.cc/v1/licenses", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AUTHFORGE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    appId: process.env.AUTHFORGE_APP_ID,
    quantity: 1,
    expiresAt: new Date(Date.now() + 14 * 86400000).toISOString(),
    label: "trial",
  }),
});
In the app, show the remaining trial period:
if is_trial:
    days_left = calculate_days_remaining()
    show_persistent_banner(f"Trial: {days_left} days remaining. Upgrade to keep using YourApp.")

Upgrade prompts

When a user on a lower tier tries to use a gated feature, show a helpful prompt instead of silently failing:
def export_project():
    features = client.license_variables.get("features", "")
    if "export" not in features:
        show_upgrade_dialog(
            title="Export requires Pro",
            message="Upgrade to the Pro plan to unlock project export.",
            action_url="https://yoursite.com/pricing"
        )
        return
    # ... actual export logic

Graceful degradation

If your app has a free tier or limited mode, fall back to it instead of killing the app entirely:
if client.login(license_key):
    run_full_app()
elif has_free_tier:
    show_notice("Running in free mode. Some features are limited.")
    run_limited_app()
else:
    show_error("Invalid license key.")
    exit(1)

Error messages

Always show user-friendly messages. Map internal error codes to helpful text:
ErrorUser sees
invalid_key”Invalid license key. Please check and try again.”
expired”Your license has expired. [Renew now]“
revoked”This license has been deactivated. Contact support.”
hwid_mismatch”This license is in use on another device. Contact support to transfer.”
no_credits”Service temporarily unavailable. Please try again in a few minutes.”
Network error”Couldn’t connect to the license server. Check your internet connection.”
Never show internal error codes like no_credits or invalid_app to end users.

Offline first launch

The initial authentication always requires a network connection. Make this clear:
┌───────────────────────────────────────────┐
│...........................................│
│...Connection Required.....................│
│...........................................│
│...YourApp needs an internet connection....│
│...for first-time activation. After that,..│
│...it works offline........................│
│...........................................│
│..............................[ Retry ]....│
│...........................................│
└───────────────────────────────────────────┘
After initial activation with LOCAL heartbeat mode, the app works offline until the prepaid session block expires (~25 hours).