> ## 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.

# Sell offline apps

> End to end: a static page with a Stripe Checkout button sells a $2.99 lifetime license with a 1-hour trial. The buyer activates once and the app runs offline forever.

This guide builds a complete sales flow for a small desktop app that should work without the internet after purchase:

1. A static page has a Stripe Checkout button. The product is a **\$2.99 lifetime license**.
2. Stripe tells AuthForge [Commerce](/features/commerce) about the payment. Commerce creates a **perpetual license with 5 seats** and emails the key to the buyer.
3. The app runs as a **1-hour free trial** until the buyer enters a key.
4. The app calls `activate_offline` once. AuthForge returns a **lifetime `.authforge` file** bound to that machine.
5. From then on the app starts from the file with no network calls, forever.

You host no server. The only online moment for the buyer is the activation call.

```mermaid theme={null}
sequenceDiagram
    participant Buyer
    participant Page as Static page
    participant Stripe
    participant AF as AuthForge
    participant App

    Buyer->>Page: Click "Buy for $2.99"
    Page->>Stripe: Stripe Checkout
    Stripe->>AF: Webhook: checkout.session.completed
    AF->>AF: Create perpetual license (5 seats)
    AF-->>Buyer: Email with license key
    Buyer->>App: Enter key (trial running or ended)
    App->>AF: POST /auth/offline/activate
    AF-->>App: Lifetime .authforge file for this machine
    App->>App: Verify and save the file
    Note over App: Every later launch: login_from_file, no network
```

<Warning>
  **A refund or chargeback revokes the license online but cannot reach files already issued.** For a lifetime file, that means the buyer keeps the app. Revoking stops new activations only. There is no remote kill switch. Choose this model only if you accept that trade-off at your price point.
</Warning>

## Before you start

You need:

* An AuthForge app (App ID, App Secret, public key).
* A Stripe account.
* One of the six SDKs in your app. The method is `activate_offline` in Python and Rust, `activateOffline` in Node.js, and `ActivateOffline` in Go, C# and C++. See [Self-serve activation](/features/offline-license-files#self-serve-activation).

Self-serve activation only works with:

* **Perpetual licenses.** Licenses with an expiry are refused with `offline_activation_requires_perpetual`. Subscriptions and time-limited licenses are not supported.
* **Seat-limited licenses.** Unlimited-seat (shared) keys are refused with `offline_activation_requires_seats`.
* **Apps that opted in.** It is off by default. Otherwise the call fails with `offline_activation_disabled`.

## What it costs

| Event | Credits |
| - | - |
| Trial launches | 0 (the app makes no AuthForge calls) |
| Purchase (Commerce creates the license) | 0 |
| First activation on a new machine | **2** (one validation + one mint), charged to you |
| Same machine activates again (reinstall, deleted file) | 0, the identical file comes back |
| Every launch after activation | 0 (local verification only) |
| Any refused activation | 0 |

A license with 5 seats can cost at most 10 credits over its whole life: 2 credits per machine, once. Most buyers use one or two machines. At the smallest credit tier ($10 for 10,000 credits) one activation costs $0.002. See [Managing credits](/best-practices/credit-management#budgeting-for-a-one-time-purchase-app).

Activations happen when buyers install, which can be months after the sale. Keep a credit balance or turn on auto-refill. With no credits, new activations fail with `no_credits`. Machines that already have a file keep working.

## 1. Turn on self-serve activation

In the dashboard, open the app's settings and turn on **Self-serve offline activation**. Or use the Developer API:

```bash theme={null}
curl -X PUT https://api.authforge.cc/v1/apps/YOUR_APP_ID \
  -H "Authorization: Bearer af_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"offlineSelfServe": {"enabled": true}}'
```

The key needs the `write:apps` scope. See [Licenses API -> Self-serve offline activation](/api/licenses#self-serve-offline-activation).

## 2. Sell the license with Stripe

### Create the product

In Stripe, create a product with a **one-time** price of \$2.99. Copy the price ID (`price_...`).

### Connect Commerce and map the price

Follow [Commerce setup](/features/commerce#setup) to connect Stripe. Subscribe the Stripe webhook to `checkout.session.completed`, and to `charge.refunded` and `charge.dispute.created` so refunds and chargebacks revoke the license.

Then add a product mapping:

| Field | Value |
| - | - |
| AuthForge application | Your app |
| Stripe price ID | The \$2.99 price |
| What kind of product is this? | **One-time purchase (lifetime or fixed length)** |
| Access length | **Blank** (perpetual license) |
| HWID slots | **5** |
| License label | `Lifetime` |

The HWID slots number is also the **machine cap**: the license can activate at most 5 distinct machines, ever. Pick a number that covers a buyer's laptop, desktop and a couple of hardware changes. Raising it later for one buyer is easy; taking a file back is impossible.

Leave **Email license key to buyer** on (the default, in the app's [portal policy](/features/portal#policy)). Commerce emails the key as soon as the payment clears. The email is plain text, so the buyer can copy the key into your app.

### Add the button to your page

A static page needs no server. Use a Stripe Payment Link or Stripe's Buy Button for the same price:

```html theme={null}
<a href="https://buy.stripe.com/your_payment_link">Buy for $2.99</a>

<!-- or Stripe's embeddable button -->
<script async src="https://js.stripe.com/v3/buy-button.js"></script>
<stripe-buy-button buy-button-id="buy_btn_..." publishable-key="pk_live_..."></stripe-buy-button>
```

Payment Link purchases arrive as `checkout.session.completed` without line items, so Commerce needs your Stripe API key to look up the price. The Commerce setup covers this.

If you host your own Stripe webhook instead of using Commerce, create the license with `expiresAt: null` and `maxHwidSlots: 5`. See [Custom Stripe webhooks](/guides/stripe).

## 3. Build the app

The app does three things on launch:

1. If a license file exists and `login_from_file` accepts it, run. No network, no credits.
2. Otherwise, if the trial has time left, run in trial mode.
3. Otherwise, ask for the key and call `activate_offline` with the file path.

If the file exists but fails (for example `hwid_mismatch` after new hardware), the app falls through to the key prompt. Activating on the new hardware counts as a new machine.

<Note>
  `activate_offline` is an online call, so the build needs the **App Secret**. This is different from air-gapped offline files, which ship without it. The secret cannot create, change or revoke licenses: activating still needs a valid, paid key. See [Security best practices](/best-practices/security).
</Note>

### Full example with the trial timer (Python)

AuthForge does not track trials. The trial below is a local timer: one hour from the first launch, stored in the app's data folder. A user who deletes that file or winds back the clock gets more trial time. For a \$2.99 app that is usually fine. If you prefer one hour of *use*, store the minutes used instead of the start time.

```python theme={null}
import json
import os
import sys
import time
from pathlib import Path

from authforge import AuthForgeClient, AuthForgeError

APP_DIR = Path(os.environ.get("LOCALAPPDATA") or Path.home() / ".local" / "share") / "MyTool"
LICENSE_PATH = APP_DIR / "license.authforge"
TRIAL_PATH = APP_DIR / "trial.json"
TRIAL_SECONDS = 60 * 60
BUY_URL = "https://example.com/buy"

MESSAGES = {
    "invalid_key": "That key is not valid. Check the email and try again.",
    "revoked": "This license has been deactivated.",
    "offline_activation_limit_reached": "This key is already activated on the maximum number of computers. Contact support to move it.",
    "hwid_mismatch": "This key has no free seat for this computer. Contact support.",
}

client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",  # needed by activate_offline
    public_key="YOUR_PUBLIC_KEY",
)


def licensed() -> bool:
    return LICENSE_PATH.exists() and client.login_from_file(str(LICENSE_PATH))


def trial_seconds_left() -> int:
    APP_DIR.mkdir(parents=True, exist_ok=True)
    try:
        started = json.loads(TRIAL_PATH.read_text())["started"]
    except (OSError, ValueError, KeyError):
        started = time.time()
        TRIAL_PATH.write_text(json.dumps({"started": started}))
    return max(0, int(started + TRIAL_SECONDS - time.time()))


def activate() -> bool:
    key = input("License key: ").strip()
    try:
        client.activate_offline(key, path=str(LICENSE_PATH))
        return True
    except AuthForgeError as exc:
        print(MESSAGES.get(exc.code, f"Activation failed ({exc.code}). Try again later or contact support."))
        return False


if licensed():
    run_app()
elif (left := trial_seconds_left()) > 0:
    print(f"Trial: {left // 60} minutes left. Buy a lifetime license at {BUY_URL}")
    run_app(trial_seconds=left)  # your app stops or nags when the timer ends
elif activate():
    run_app()
else:
    sys.exit(1)
```

Also give the user an **Enter license key** action during the trial that calls `activate()`, so a buyer does not have to wait for the trial to end.

### The activation pattern in every SDK

The core is the same in every language: try the file, otherwise prompt and activate. On `offline_activation_limit_reached`, tell the user to contact you.

<CodeGroup>
  ```python Python theme={null}
  import os
  import sys
  from authforge import AuthForgeError

  if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
      key = prompt_for_key()
      try:
          client.activate_offline(key, path=LICENSE_PATH)
      except AuthForgeError as exc:
          if exc.code == "offline_activation_limit_reached":
              show_error("This key is already activated on the maximum number of computers. Contact support.")
          else:
              show_error(f"Activation failed ({exc.code}).")
          sys.exit(1)
  ```

  ```js Node.js theme={null}
  import fs from "node:fs";
  import { AuthForgeError } from "@authforgecc/sdk";

  if (!(fs.existsSync(LICENSE_PATH) && client.loginFromFile(LICENSE_PATH))) {
    const key = await promptForKey();
    try {
      await client.activateOffline(key, { path: LICENSE_PATH });
    } catch (error) {
      const code = error instanceof AuthForgeError ? error.code : "unknown_error";
      showError(code === "offline_activation_limit_reached"
        ? "This key is already activated on the maximum number of computers. Contact support."
        : `Activation failed (${code}).`);
      process.exit(1);
    }
  }
  ```

  ```go Go theme={null}
  if _, err := client.LoginFromFile(licensePath); err != nil {
  	key := promptForKey()
  	if err := client.ActivateOffline(key, licensePath); err != nil {
  		if errors.Is(err, authforge.ErrOfflineActivationLimitReached) {
  			log.Fatal("This key is already activated on the maximum number of computers. Contact support.")
  		}
  		log.Fatalf("Activation failed (%s).", authforge.ErrorCode(err))
  	}
  }
  ```

  ```rust Rust theme={null}
  if client.login_from_file(LICENSE_PATH).is_err() {
      let key = prompt_for_key();
      if let Err(err) = client.activate_offline(&key, Some(Path::new(LICENSE_PATH))) {
          if err.code() == "offline_activation_limit_reached" {
              eprintln!("This key is already activated on the maximum number of computers. Contact support.");
          } else {
              eprintln!("Activation failed ({}).", err.code());
          }
          std::process::exit(1);
      }
  }
  ```

  ```csharp C# theme={null}
  if (!(File.Exists(licensePath) && client.LoginFromFile(licensePath)))
  {
      var key = PromptForKey();
      try
      {
          client.ActivateOffline(key, licensePath);
      }
      catch (AuthForgeException ex)
      {
          ShowError(ex.Code == "offline_activation_limit_reached"
              ? "This key is already activated on the maximum number of computers. Contact support."
              : $"Activation failed ({ex.Code}).");
          Environment.Exit(1);
      }
  }
  ```

  ```cpp C++ theme={null}
  if (!(std::filesystem::exists(licensePath) && client.LoginFromFile(licensePath))) {
    const std::string key = PromptForKey();
    try {
      client.ActivateOffline(key, licensePath);
    } catch (const authforge::AuthForgeError& e) {
      ShowError(e.code() == "offline_activation_limit_reached"
                    ? "This key is already activated on the maximum number of computers. Contact support."
                    : "Activation failed (" + e.code() + ").");
      return 1;
    }
  }
  ```
</CodeGroup>

What the SDK does on `activate_offline`:

* Sends one request to `POST /auth/offline/activate` with the key, this machine's HWID (the same one `login()` would send) and a fresh nonce.
* Verifies the returned file locally (signature, app id, this machine's HWID) **before** saving it. A file that fails is refused with `offline_file_rejected` and nothing is written.
* Writes the file to the path atomically and creates parent folders. If the write fails, you get `file_write_failed`; calling again returns the same file for free.
* Leaves the client in an offline session, exactly as after `login_from_file`: variables are available, no grace-period timer, no check-ins.
* Never calls your failure callback and never exits the process. A failed activation leaves any existing session alone. You decide what to show.

`login_from_file` still reports a missing or rejected file through your failure callback as `offline_login_failed` in some SDKs. If you set a callback, let it return normally for that reason, or the app could exit before it reaches the key prompt.

## 4. Show clear errors

Every refusal is free: no credits are charged, no seat is bound and no machine is counted.

| Code | Cause | Tell the user |
| - | - | - |
| `invalid_key` | The key does not exist for this app (usually a typo) | "That key is not valid. Check the email and try again." |
| `revoked` | The license was revoked, for example after a refund | "This license has been deactivated." |
| `offline_activation_limit_reached` | The license already activated its maximum number of machines | "This key is already activated on the maximum number of computers. Contact support to move it." |
| `hwid_mismatch` | No free seat on the license | "This key has no free seat for this computer. Contact support." |
| `offline_activation_disabled` | The app has not opted in | Your configuration. "Activation is unavailable. Contact support." |
| `offline_activation_requires_perpetual` | The license has an expiry | Your product mapping. Same message. |
| `offline_activation_requires_seats` | The license is an unlimited-seat key | Your product mapping. Same message. |
| `no_credits`, `app_burn_cap_reached` | Your account balance or burn cap | "Activation is temporarily unavailable. Try again later." Never mention billing. |
| `rate_limited`, `network_error`, `timeout` | Too many attempts (10/min per IP, 3/min per license) or no connection | "Could not reach the activation server. Try again in a minute." |
| `offline_file_rejected` | The file from the server failed local verification, usually because the build has the wrong public key | "Activation failed. Contact support." |
| `file_write_failed` | The verified file could not be saved to the path (permissions, full disk) | "Could not save the license file to {folder}." Retrying is free. |

The full list is in the [Error codes reference](/api/errors#self-serve-activation-errors).

## 5. Support buyers who change machines

The license can activate at most **5 distinct machines, ever** (its HWID slots). The rules:

* **Same machine again** (reinstall, deleted file, lost response): free, and the identical file comes back. It never counts twice.
* **New or changed hardware**: counts as a new machine and costs 2 credits.
* **HWID resets do not help.** A reset in the [portal](/features/portal) or dashboard frees seats, but never machine slots, because the old machine's file keeps working.
* **Cap reached**: the app gets `offline_activation_limit_reached` and tells the buyer to contact you.

When a buyer writes in, you can:

* **Release an offline machine** on the license page in the dashboard, or with `DELETE /v1/licenses/{licenseKey}/offline-machines/{hwidHash}`. This frees one machine slot and that machine's seat. The released machine's file **keeps working**, so release only when you trust the request (for example "my old PC died"). `GET /v1/licenses/{licenseKey}/offline-machines` lists the machines.
* **Raise the license's HWID slots** in the dashboard (up to 16).
* **Mint a file by hand** for a specific HWID ([Offline license files](/features/offline-license-files#minting-a-file)). Hand-minted files do not count against the machine cap.

## What this does not do

* **No time-limited licenses.** Subscriptions and fixed-term licenses cannot self-activate. Use online `login()` and the [grace period](/concepts#activation-and-the-grace-period) for those.
* **No unbound files at checkout.** Every self-serve file is bound to one machine's HWID.
* **No remote kill.** Refunds, chargebacks and revocation stop new activations only. Issued files keep working.

## Checklist

* [ ] Self-serve offline activation turned on for the app
* [ ] Stripe price mapped as a one-time purchase with blank access length and several HWID slots
* [ ] Refund and dispute events subscribed, accepting that they cannot reach issued files
* [ ] Buyer email on, with your support contact in the portal policy
* [ ] App build has the App ID, App Secret and public key
* [ ] Launch order: file, then trial, then key prompt + `activate_offline` with a path
* [ ] Clear message for `offline_activation_limit_reached` that points to support
* [ ] Credit balance or auto-refill for activations that happen long after the sale

## Next steps

* [Offline license files](/features/offline-license-files#self-serve-activation): the feature in detail
* [Commerce](/features/commerce): the managed Stripe and Lemon Squeezy pipeline
* [Offline licensing best practices](/best-practices/offline-licensing): choosing between air-gapped files, self-serve activation and online activation


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.