# Quickstart for Tauri

Add license keys and offline activation to a Tauri 2 app with tauri-plugin-keyrail.

This guide adds licensing to an existing Tauri 2 app. The Rust side (the `keyrail` crate, wrapped by the plugin) caches and verifies the certificate; your frontend calls four commands: `check`, `activate`, `deactivate` and `status`. Network and disk work runs on Tauri’s blocking thread pool, never on the UI thread.

## Before you start

In the dashboard, create a product (**Products → New product**) and at least one plan (**Plans → New plan**); the **Get started** checklist on the **Overview** screen walks you through both. Copy two values from the product page, under **Signing keys**:

- the **Product ID**, like `prd_7k2m9q4x1c8d3f6h0j5n`;
- the **Active public key** `k1`, a 43-character base64url string.

Both are public: they are meant to be compiled into your app. To create test licenses without a payment provider, use **Licenses → New license** in the dashboard or [`POST /v1/licenses`](/docs/vendor-api/#licenses).

## 1. Install the plugin


```bash
# Rust side, in src-tauri/
cargo add tauri-plugin-keyrail

# JavaScript side, in your app root
npm install @keyrail/tauri-plugin
```

## 2. Register it


```rust title="src-tauri/src/lib.rs"
use tauri_plugin_keyrail::Config;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let keyrail = Config::new(
        "prd_7k2m9q4x1c8d3f6h0j5n",
        [("k1", "pZ3vC0x6hYjQ8m1K4tR9wN2bF7sL5dA0eUqTgHiOcVk")],
    );
    // Debug builds only: point the plugin at a local service (see "Try it locally" below).
    #[cfg(debug_assertions)]
    let keyrail = match std::env::var("KEYRAIL_API") {
        Ok(api_base) => keyrail.api_base(api_base),
        Err(_) => keyrail,
    };

    tauri::Builder::default()
        .plugin(tauri_plugin_keyrail::init(keyrail))
        .run(tauri::generate_context!())
        .expect("error while running the app");
}
```

The app version checked against `maxMajor` defaults to the version of your Tauri app, and the certificate is cached in `keyrail-license.json` in the app data directory. Optional settings on `Config`: `.api_base(…)`, `.app_version("1.4.2")`, `.cache_path("license.json")` (relative paths are resolved against the app data directory) and `.machine_name(None)` to send no machine name (the host name is sent by default). An invalid configuration, such as a malformed public key, makes the app fail at setup.

Allow the frontend to call it in your capability file:


```json title="src-tauri/capabilities/default.json"
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": ["core:default", "keyrail:default"]
}
```

`keyrail:default` allows the four commands. For finer control, use `keyrail:allow-activate`, `keyrail:allow-check`, `keyrail:allow-deactivate` and `keyrail:allow-status`.

## 3. Check at startup, activate on demand


```ts title="src/license.ts"
import { activate, check, deactivate, isKeyrailError, type LicenseState } from '@keyrail/tauri-plugin';

// At every launch: verifies the cached certificate offline. Once a day (after refreshAfter),
// it revalidates online first; if the network is down, the cached certificate is used.
// License problems are reported in the state; check() rejects only for environment errors.
export function currentLicense(): Promise<LicenseState> {
  return check();
}

// From your "Enter license key" dialog.
export async function activateKey(key: string): Promise<{ ok: boolean; message?: string }> {
  try {
    const certificate = await activate(key); // the verified certificate
    return { ok: true, message: `Licensed to ${certificate.licensee ?? 'you'}` };
  } catch (e) {
    if (isKeyrailError(e) && e.code === 'activation_limit') {
      return { ok: false, message: 'This key is already used on the maximum number of machines.' };
    }
    if (isKeyrailError(e)) return { ok: false, message: e.message };
    throw e;
  }
}

// From a "Deactivate this computer" menu item: frees the seat, then clears the cache.
export const release = () => deactivate();
```

`check()` resolves to a `LicenseState`: `{ valid, reason, certificate, offline, maskedKey }`. Only `valid: true` unlocks the app. `reason` is `null` when valid, otherwise `no_license` (never activated on this machine), one of the [verification outcomes](/docs/offline-certificates/#outcomes) (`expired`, `wrong_machine`, `version_not_covered`, `clock_tampered`…) or a definitive server refusal (`license_expired`, `license_suspended`, `license_refunded`, `license_revoked`, `unknown_key`, `unknown_product`, `not_activated`). `certificate` holds the last authentic payload (`plan`, `entitlements`, `status`, `licensee`, `validUntil`, `expiresAt`…), even when it no longer grants access. `offline: true` means the online refresh failed and the cached certificate was used. `maskedKey` is the key with its middle groups hidden, for a settings screen.

`activate` and `deactivate` reject with a `KeyrailError` (`{ code, message }`, use `isKeyrailError`): a [client API error code](/docs/client-api/#errors) (`unknown_key`, `license_expired`, `activation_limit`, `rate_limited`…), `invalid_request` for a badly formatted key, `network_error` or `invalid_response` when no usable answer came back, `invalid_certificate` when the certificate returned does not verify with the configured public keys (bad signature, unknown `kid`; nothing is cached), or a verification outcome of the new certificate. When `deactivate` rejects with `network_error`, the cache is kept: retry later. `status()` returns the offline-only state, with no network call.

When an online refresh is refused definitively (`403 license_*`, `404 unknown_key` or `not_activated`), the plugin clears its cache and `check()` reports that code; network failures, `rate_limited` and server errors keep the cached certificate while it is valid.

## 4. Gate features

Entitlements are strings you define on the plan. They are inside the signed certificate, so they cannot be edited on the customer’s machine.


```ts
import { hasEntitlement } from '@keyrail/tauri-plugin';

const canExport = hasEntitlement(await currentLicense(), 'export');
```

The webview can be modified: check from Rust too, before running a privileged command. `License::check()` is blocking (it may refresh online), so run it off the async runtime:


```rust
use tauri_plugin_keyrail::KeyrailExt;

#[tauri::command]
async fn export_pdf(app: tauri::AppHandle) -> Result<(), String> {
    let keyrail = app.keyrail().clone();
    let allowed = tauri::async_runtime::spawn_blocking(move || {
        keyrail
            .license()
            .check()
            .is_ok_and(|certificate| certificate.has_entitlement("export"))
    })
    .await
    .map_err(|e| e.to_string())?;
    if !allowed {
        return Err("Export needs a license".into());
    }
    // ...
    Ok(())
}
```

## 5. Try it locally

From a checkout of the Keyrail repository, `cd e2e && npm run demo-server` starts a throwaway local service (`wrangler dev`, local database) on `127.0.0.1:8787`, creates a product, a perpetual plan and a test license, and prints `KEYRAIL_API`, `KEYRAIL_PRODUCT`, `KEYRAIL_PUBLIC_KEY` (`kid:key`) and the test license key. Put the printed product ID and public key in `Config::new`, and run `tauri dev` with `KEYRAIL_API=http://127.0.0.1:8787/v1` (read by the snippet above in debug builds). `examples/tauri-demo` in the repository reads all three variables. Stop the server with Ctrl+C: its database is deleted.

Then, against the local service or production:

1. Create a license in the dashboard (**Licenses → New license**) and copy its key.
2. Run your app, paste the key: the machine shows up under **Machines** on the license page in the dashboard.
3. Turn the network off and restart the app: `check()` still returns `valid: true` (with `offline: true` once a refresh is due).
4. Click **Suspend** on the license page and turn the network back on: the next refresh (once `refreshAfter` has passed) is refused with `license_suspended`, the plugin clears its cache and `check()` returns `valid: false`. An app that stays offline keeps access until its current certificate expires. See [how changes reach installed apps](/docs/concepts/#how-changes-reach-installed-apps).

Next: [connect Paddle Billing](/docs/paddle/) or [Lemon Squeezy](/docs/lemon-squeezy/) so purchases create licenses automatically.
