> ## Documentation Index
> Fetch the complete documentation index at: https://docs.authforge.cc/llms.txt
> Use this file to discover all available pages before exploring further.

# UX Patterns for Licensed Software

> Best practices for creating a smooth user experience in license-gated applications.

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.

<div className="not-prose my-6 max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-950">
  <p className="text-center text-base font-semibold text-zinc-900 dark:text-zinc-100">Welcome to YourApp</p>
  <p className="mt-2 text-center text-sm text-zinc-600 dark:text-zinc-400">Enter your license key to get started</p>

  <div className="mt-4 rounded-md border border-zinc-300 bg-zinc-50 px-3 py-2 text-center font-mono text-sm tracking-wide text-zinc-800 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200">
    XXXX-XXXX-XXXX-XXXX
  </div>

  <div className="mt-5 flex flex-wrap items-center justify-between gap-3 text-sm">
    <span className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-700 dark:border-zinc-600 dark:text-zinc-300">Purchase</span>

    <span className="rounded-md px-3 py-2 font-medium text-white" style={{ backgroundColor: "#7F77DD" }}>
      Activate
    </span>
  </div>
</div>

* 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:

```bash theme={null}
# 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.

```python theme={null}
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:

```python theme={null}
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:

| Field       | Value                                    |
| ----------- | ---------------------------------------- |
| License key | `A3K9-****-****-QHDT` (partially masked) |
| Status      | Active                                   |
| Plan        | Pro                                      |
| Expires     | December 31, 2026                        |
| Devices     | 1 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:

```python theme={null}
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:

<div className="not-prose my-6 max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-950">
  <p className="text-center text-base font-semibold text-zinc-900 dark:text-zinc-100">Your license has expired</p>

  <p className="mt-3 text-center text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
    Your license expired on December 31, 2026.

    <br />

    Renew to continue using YourApp.
  </p>

  <div className="mt-5 flex flex-wrap items-center justify-between gap-3 text-sm">
    <span className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-700 dark:border-zinc-600 dark:text-zinc-300">Enter New Key</span>

    <span className="rounded-md px-3 py-2 font-medium text-white" style={{ backgroundColor: "#7F77DD" }}>
      Renew
    </span>
  </div>
</div>

## Trial mode

Use the Developer API to issue time-limited trial licenses:

```javascript theme={null}
// 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:

```python theme={null}
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:

```python theme={null}
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:

```python theme={null}
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:

| Error           | User 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:

<div className="not-prose my-6 max-w-md rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-950">
  <p className="text-center text-base font-semibold text-zinc-900 dark:text-zinc-100">Connection Required</p>

  <p className="mt-3 text-center text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
    YourApp needs an internet connection for first-time activation. After that, it works offline.
  </p>

  <div className="mt-5 flex justify-end text-sm">
    <span className="rounded-md px-3 py-2 font-medium text-white" style={{ backgroundColor: "#7F77DD" }}>
      Retry
    </span>
  </div>
</div>

After initial activation with LOCAL heartbeat mode, the app works offline until the session token expires (24 hours by default; up to 7 days if the SDK was configured with a longer `ttlSeconds`).
