Get started
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#
npm install @keyrail/clientYou need your product ID and public key k1 from the product page in the dashboard. Both are public values.
2. Create the license helper in the main process#
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#
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'),
});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 aLicenseState:{ valid: true, certificate, offline }or{ valid: false, reason, certificate?, error? }. Onlyvalid: trueunlocks the app.offline: truemeans the online refresh failed and the cached certificate was used.reasonisno_license(never activated, or cache cleared), a verification outcome 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 aKeyrailApiErrorwith a client API error code (activation_limit,unknown_key,license_revoked…),invalid_requestfor a badly formatted key,network_error,timeout,invalid_responsewhen no usable answer came back, orinvalid_certificatewhen the certificate returned does not verify with yourpublicKeys(bad signature, unknownkid: the app does not embed the product's signing key; nothing is cached). Otherwise it resolves to theLicenseStateof the new certificate, which can still be invalid (for exampleversion_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#
- It verifies the cached certificate offline, with the public keys compiled into your app.
- Once
refreshAfterhas passed (one day after issue), or when the offline verification failed for a reason a fresh certificate could fix (anything butclock_tampered), it calls/v1/licenses/validatebefore resolving. Concurrent calls share one run. - Network failure, timeout,
rate_limitedor a server error: the cached certificate is kept and used while it is valid (offline: true). - Definitive refusal (
403 license_*, or404 unknown_key,unknown_product,not_activated): the cache is cleared andcheck()resolves to{ valid: false, reason: 'license_revoked' }(or the code received). Only the highestissuedAtseen is kept, for clock rollback detection. - 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.