Installation and setup
Install
npm
npm install @licenseman/electronelectron 28 or later is a peer dependency. The package also installs @napi-rs/keyring as an optional dependency, a native module the SDK uses to reach the system keychain: see packaging.
Create the LicenseManager
Copy your product’s id and public key
Open Security in the sidebar: copy the public key, and the id of the product, like x7Kp2QaZ.
Create one instance, in the main process
Create it once the app is ready, and keep it for the lifetime of the app:
import { app } from "electron";
import { LicenseManager } from "@licenseman/electron";
const PRODUCT_ID = "x7Kp2QaZ";
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA…
-----END PUBLIC KEY-----`;
let license: LicenseManager;
app.whenReady().then(async () => {
license = new LicenseManager({ productId: PRODUCT_ID, publicKey: PUBLIC_KEY });
license.onStateChange((state) => sendStateToWindows(state));
const state = await license.ready();
if (state === "ACTIVE") openMainWindow();
else openActivationWindow();
});Dispose it when the app quits
app.on("will-quit", () => license?.dispose());dispose() stops the background re-validation and the hourly clock check. Their timers don’t keep the process alive either way, so forgetting it doesn’t prevent the app from quitting.
Hard-code the public key and the product id in your source code. Never load them from a file next to your app, or fetch them at runtime: whoever replaces the public key can sign their own tokens, and your app would trust them. See the public key.
What happens on construction
The constructor returns right away, and starts in the background:
- It reads the stored key. No key: the state becomes
MISSING_KEYbefore the constructor returns, andready()resolves with it. - With a key and a valid stored token, the state becomes
ACTIVE, andready()resolves with it right away. The online validation runs in the background: when it refuses the license, the state changes, and your listener is called. - With a key but no valid stored token, it validates the key online, retrying for up to 30 seconds, and
ready()resolves once it’s done, withACTIVEor the reason the app is locked. - It starts the clock check, now and every hour.
The constructor throws only when productId or publicKey is empty. Everything else, a missing keychain, no network, a broken fingerprint command, is handled without throwing.
Because the MISSING_KEY state is set during the constructor, a listener added afterwards never sees that change. Always use the value ready() resolves with, or getState(), for the first screen, and the listener for the changes after it.
One instance per product
Treat the LicenseManager as a singleton. Two instances with the same productId share the same stored data, but not their state or timers: they’d both validate in the background, and could disagree after one of them activates a new key.
An app selling several products, like an app and a paid plugin with their own licenses, creates one instance per product, each with its own productId. Their keys, tokens and fingerprints are stored separately and never overlap.
Options
Most of the time, you only need productId and publicKey. The other options have defaults that fit most apps.
new LicenseManager({
productId: "x7Kp2QaZ", // required
publicKey: PUBLIC_KEY, // required
version: "2.3.1", // default: app.getVersion()
machineName: "Jane's MacBook", // default: the hostname
platform: "macOS", // default: macOS, Windows or Linux
fingerprint: () => readMyId(), // default: the system's machine id
minOfflineDurationMs: 3 * 24 * 60 * 60 * 1000, // default: 0
});| Option | Default | What it does |
|---|---|---|
productId | Required | The id of your product. Checked in every token’s aud, and used to keep each product’s stored data apart. Shown on the Security page. |
publicKey | Required | Your organization’s Ed25519 public key, as the PEM shown on the Security page. Verifies every token. |
version | app.getVersion() | The version of your app, sent with every validation and checked offline against the token’s mv. |
machineName | os.hostname() | The name of the machine, shown to you and in the customer portal. |
platform | macOS, Windows or Linux | The machine’s platform. On other systems, Node’s process.platform, like freebsd. |
fingerprint | The system’s machine id | The machine part of the fingerprint: a string, or a function returning one, sync or async. See machine fingerprint. |
minOfflineDurationMs | 0 | How long, at least, your app must keep working offline after a renewal. Makes renewals happen earlier. See renewal. |
apiBaseUrl | https://api.licensemanager.dev | For tests only: another API origin. Trailing slashes are removed. |
storageDirs | Per system | For tests only: the folders of the file fallback. |
version
By default, it’s Electron’s app.getVersion(), the version of your app’s package.json. Two cases to know:
- In development, when your
package.jsonhas noversion,app.getVersion()returns Electron’s version, like33.2.0, which can be refused withversion_not_allowed. Set a version inpackage.json, or passversion. - Outside Electron, like in unit tests run by Node.js, there’s no
app: passversion, or every validation fails. InsetLicenseKey, it throwsversion is required when not running in Electron; in the background, it’s handled like a network error.
machineName and platform
Both are sent with every validation, and replace what LicenseManager knows about the machine. The hostname often holds the customer’s name, like Janes-MacBook-Pro.local, which is what makes machines easy to recognize in the customer portal. Pass your own value if you’d rather not send it.
Development and release builds
Point your development builds at a test product, and your release builds at the live one. Both share the same public key, which is one per organization:
const PRODUCT_ID = app.isPackaged ? "x7Kp2QaZ" : "t3Hw9LmB";A test key validated against the live product answers not_found, and the opposite too: the SDK shows INVALID_KEY.
Since both products share the public key, the product id is what stops a test key from unlocking your release build: the SDK checks it in every token. Make sure release builds can’t end up with the test product’s id, like from an environment variable a customer could set.
Packaging
The keychain is reached through @napi-rs/keyring, a native module. When it can’t be loaded, the SDK silently falls back to files: your app keeps working, but the key and token are less protected. Check these when you package your app:
- Keep it in your dependencies. Don’t install with
--no-optional, or--omit=optional: it’s an optional dependency. - Install it for each target. It ships one binary per system and architecture, like
@napi-rs/keyring-darwin-arm64, and your package manager only installs the one of the computer it runs on. Build each platform on its own system, like with one CI job per platform, or install the binaries of the platforms you target. For a universal macOS app, you need bothdarwin-arm64anddarwin-x64. - Unpack it from the asar archive. Native modules can’t be loaded from inside
app.asar. electron-builder unpacks them by default; with Electron Forge, add@electron-forge/plugin-auto-unpack-natives. - Don’t bundle it. If you bundle your main process with Vite, webpack or esbuild, mark
@napi-rs/keyringas external: the SDK loads it withrequireat runtime, and a bundler that can’t see it leaves it out, without an error.
To check it works, activate a key in your packaged app, then look for items of the service dev.licensemanager.sdk in the Keychain Access app on macOS, or the Credential Manager on Windows.
Network access
The SDK makes two kinds of requests, from the main process, with Node’s fetch:
| Request | When | Timeout |
|---|---|---|
POST https://api.licensemanager.dev/v1/products/{productId}/validate-key | Each validation | 10 seconds |
HEAD https://google.com, to read the time from its Date header | On launch, then every hour, for the clock check | 5 seconds |
Requests from the main process don’t go through your windows’ Content Security Policy. They don’t go through the proxy configured in Chromium either: on a network that only allows traffic through a proxy, they fail, and the SDK handles it like being offline.