# Quickstart for Electron

License an Electron app with @keyrail/client in the main process, exposed to the renderer over IPC.

`@keyrail/client` runs in Node 18 or later, with no runtime dependency (it uses `node:crypto` for Ed25519). In Electron, use it in the **main process** only: it reads the machine identifier and stores the certificate on disk, which the sandboxed renderer must not do. The renderer talks to it through a small preload bridge.

## 1. Install


```bash
npm install @keyrail/client
```

You need your **Product ID** and **Active public key** `k1` from the product page in the dashboard (**Products**, then your product, under **Signing keys**). Both are public values.

## 2. Create the license helper in the main process


```ts title="src/main/license.ts"
import path from 'node:path';
import { app, ipcMain } from 'electron';
import { KeyrailApiError, License, type LicenseState } from '@keyrail/client';

// Development only: point the SDK at a local service (see "Test locally" below).
const devApiBase = app.isPackaged ? undefined : process.env.KEYRAIL_API;

export const license = new License({
  productId: 'prd_7k2m9q4x1c8d3f6h0j5n',
  publicKeys: { k1: 'pZ3vC0x6hYjQ8m1K4tR9wN2bF7sL5dA0eUqTgHiOcVk' },
  appVersion: app.getVersion(), // its major is compared with maxMajor
  storagePath: path.join(app.getPath('userData'), 'license.json'), // cache file
  ...(devApiBase ? { apiBase: devApiBase } : {}),
});

// What the renderer gets: plain data, never the key or the raw certificate.
export type RendererLicense =
  | { valid: true; plan: string; entitlements: string[]; offline: boolean }
  | { valid: false; reason: string };

function forRenderer(state: LicenseState): RendererLicense {
  return state.valid
    ? {
        valid: true,
        plan: state.certificate.plan,
        entitlements: state.certificate.entitlements,
        offline: state.offline,
      }
    : { valid: false, reason: state.reason };
}

export function registerLicenseIpc(): void {
  // Offline verification; revalidates online once refreshAfter has passed.
  // Never throws for network or server errors.
  ipcMain.handle('license:check', async () => forRenderer(await license.check()));

  ipcMain.handle('license:activate', async (_event, key: string) => {
    try {
      return forRenderer(await license.activate(key));
    } catch (e) {
      // Server refusal (activation_limit, unknown_key...) or no answer (network_error, timeout).
      if (e instanceof KeyrailApiError) return { valid: false, reason: e.code };
      throw e;
    }
  });

  ipcMain.handle('license:deactivate', () => license.deactivate());
}
```

The machine name sent on activation defaults to `os.hostname()`: it helps you and your customer recognize machines in the dashboard and the portal. Pass `machineName: null` to send none.

Gate privileged work in the main process too, for example `hasEntitlement(await license.check(), 'export')` (imported from `@keyrail/client`) before writing the export file: a renderer can be modified, the main process is where the decision belongs.

## 3. Expose it to the renderer


```ts title="src/preload/index.ts"
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('license', {
  check: () => ipcRenderer.invoke('license:check'),
  activate: (key: string) => ipcRenderer.invoke('license:activate', key),
  deactivate: () => ipcRenderer.invoke('license:deactivate'),
});
```


```ts title="src/renderer/app.ts"
const state = await window.license.check();

if (state.valid) {
  showApp({ canExport: state.entitlements.includes('export') });
} else {
  // state.reason: "no_license" before the first activation, or why the license is not usable.
  showActivationDialog(async (key) => {
    const result = await window.license.activate(key);
    if (result.valid) return null;
    if (result.reason === 'activation_limit') {
      return 'This key is already active on the maximum number of machines.';
    }
    return `Activation failed (${result.reason}).`;
  });
}
```

## States and errors

- `check()` resolves to a `LicenseState`: `{ valid: true, certificate, offline }` or `{ valid: false, reason, certificate?, error? }`. Only `valid: true` unlocks the app. `offline: true` means the online refresh failed and the cached certificate was used.
- `reason` is `no_license` (never activated, or cache cleared), a [verification outcome](/docs/offline-certificates/#outcomes) of the cached certificate (`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`).
- `activate(key)` throws a `KeyrailApiError` with a [client API error code](/docs/client-api/#errors) (`activation_limit`, `unknown_key`, `license_revoked`…), `invalid_request` for a badly formatted key, `network_error`, `timeout`, `invalid_response` when no usable answer came back, or `invalid_certificate` when the certificate returned does not verify with your `publicKeys` (bad signature, unknown `kid`: the app does not embed the product's signing key; nothing is cached). Otherwise it resolves to the `LicenseState` of the new certificate, which can still be invalid (for example `version_not_covered`).
- `deactivate()` frees the seat, then clears the cache. If the server cannot be reached it throws and keeps the cache, so the call can be retried.
- `status()` gives the offline-only state, with no network call.

## How check() behaves

1. It verifies the cached certificate **offline**, with the public keys compiled into your app.
2. Once `refreshAfter` has passed (one day after issue), or when the offline verification failed for a reason a fresh certificate could fix (anything but `clock_tampered`), it calls [`/v1/licenses/validate`](/docs/client-api/#validate) before resolving. Concurrent calls share one run.
3. Network failure, timeout, `rate_limited` or a server error: the cached certificate is kept and used while it is valid (`offline: true`).
4. Definitive refusal (`403 license_*`, or `404 unknown_key`, `unknown_product`, `not_activated`): the cache is cleared and `check()` resolves to `{ valid: false, reason: 'license_revoked' }` (or the code received). Only the highest `issuedAt` seen is kept, for clock rollback detection.
5. A new authentic certificate is stored, even when it does not grant access (for example `version_not_covered`). An answer that does not verify is ignored.

The cache file at `storagePath` is JSON (`version`, `key`, `certificate`, `highestIssuedAt`). It holds the license key in clear, because silent revalidation and deactivation need it: keep it under `app.getPath('userData')`. It is written atomically, with mode `0600` on macOS and Linux.

## Test 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. Start your unpackaged app with `KEYRAIL_API=http://127.0.0.1:8787/v1` (the snippet above passes it as `apiBase`), and use the printed product ID and public key. Stop the server with Ctrl+C: its database is deleted.

## Without Electron

The same `License` class works in any Node program (a CLI, a local server). Pass `storagePath` explicitly, under a per-user directory; `appVersion` is your program’s version. For lower-level needs, `KeyrailClient` calls the three client API endpoints and `verifyCertificate` verifies a certificate offline.

Next: [connect a payment provider](/docs/paddle/).
