Skip to content

Offline integration

For products that must run without a connection. The app activates once online and receives an Ed25519-signed license token. It stores the token and verifies it locally on every launch. It re-checks online periodically (the lease), so revocation and expiry still take effect.

  1. Activate (online, once). Send the typed key, a device fingerprint, and a device name. Receive a signed token.
  2. Store the token locally, for example in your app’s data folder.
  3. Verify offline (every launch). Check the signature against the embedded public key, confirm the token is bound to this machine, and check expiry. No network needed.
  4. Heartbeat (occasionally, online). Re-issue the token to renew the lease (30 days by default). This is what makes revocation reach machines that are already activated.
activate ──► token ──► [store] ──► verify offline on every launch
└── periodically ──► heartbeat ──► fresh token

The TypeScript SDK is delivered as a package file, not through the public npm registry. Download it from theIntegration page of the Waveform app in your store admin (or from your Waveform account), then install it from disk:

Terminal window
npm install ./waveform-licensing-sdk.tgz

Prefer not to install anything? Every example below also works as plain HTTP.

import { createClient, computeFingerprint } from "@waveform/licensing";
const license = createClient({
apiBaseUrl: "https://api.waveformanager.com",
publicKey: BAKED_PUBLIC_KEY, // from the Integration page; embedded, safe to ship
});
const APP_SALT = "your-app-fp-salt-v1"; // a constant compiled into your build
const fingerprint = computeFingerprint(rawMachineId(), APP_SALT); // see "Fingerprinting" below
const r = await license.activate({
key: enteredKey,
fingerprint,
deviceName: "Studio Mac", // user-editable; shown in the device manager
productId: BAKED_PRODUCT_ID, // the product's internal ID; strongly recommended
});
if (r.ok) {
saveToken(r.token); // persist locally
} else if (r.error === "seat-limit") {
// r.devices lists the bound devices. Let the user pick one to free, then retry:
await license.deactivate({ key: enteredKey, deviceId: r.devices[0].id });
// ...retry activate
} else if (r.error === "activation-limit") {
// Not a seat problem: freeing a device will NOT clear this. Say "contact support".
} else {
// "invalid-key" | "revoked" | "expired" | "blocked" | "product-mismatch" | "network" | ...
}
const r = await license.startDemo({ productId: BAKED_PRODUCT_ID, fingerprint, deviceName });
if (r.ok) saveToken(r.token); // a time-limited demo token; one demo per machine

Use evaluateOffline. It is the supported entry point, and it is not just a convenience wrapper: it wires together four defenses that each fail open when you call them separately.

import { evaluateOffline } from "@waveform/licensing";
const gate = evaluateOffline({
token: loadToken(),
publicKeyRawB64: BAKED_PUBLIC_KEY, // or [CURRENT, NEXT] during a key rotation
expectedProductId: BAKED_PRODUCT_ID, // rejects a sibling product's (or a demo's) token
fingerprint, // this machine's
sealedMark: loadSealedMark(), // null on first run
clockKey: BAKED_CLOCK_HMAC_KEY, // an app constant that seals the clock mark
maxOfflineDays: 14, // must reconnect within this window
lastOnlineAt: loadLastOnlineAt(),
});
saveSealedMark(gate.nextMark); // ALWAYS persist, to every storage location you use
if (gate.allowed) {
unlockApp(gate.payload!.feat);
if (gate.inGrace) warnLicenseExpiringSoon();
if (gate.clockSuspect) noteClockAnomaly(); // nag, don't crash
if (gate.mustReValidateOnline) scheduleHeartbeat();
} else {
// gate.reason: "bad-signature" | "fingerprint-mismatch" | "product-mismatch"
// | "expired" | "online-check-required" | "invalid-claims" | ...
showActivationScreen();
}

In order, it does: strict canonical decode, Ed25519 signature over the raw payload, algorithm and version assertion (blocks downgrade), claim-shape validation, product binding, machine binding, time evaluated at a monotonic clock, and the offline deadline. No network.

Why not call the low-level verifier yourself

Section titled “Why not call the low-level verifier yourself”
If you… …you lose
omit fingerprint machine binding. A copied license file runs on every machine. (It now fails closed with fingerprint-required, but allowAnyDevice would silently restore the hole.)
omit expectedProductId product binding. One product’s token unlocks another, because signing keys are per merchant.
pass the system clock as now rollback defense. Winding the clock back revives an expired lease.
skip maxOfflineDays the only backstop against a machine that never reconnects and so never learns it was revoked. (Under the deactivate seat policy this backstop is deliberately given up; see Seat policies.)

verifyLicenseToken stays exported for tooling and tests. It is the low-level primitive, not the gate.

The lease bounds this on its own. Under the default lease policy the server signs exp at most 30 days out (the server operator can shorten this) and the client cannot extend it.

Scenario Result
Activates once, offline forever, honest clock, maxOfflineDays: 0 Runs 44 days (30-day lease + 14-day grace), then stops with expired
Same, with maxOfflineDays: 14 Stops after 14 days with online-check-required
Clock frozen, sealed mark intact Caught: the mark’s high-water time still advances the verdict
Clock frozen at iat and the mark deleted on every launch Runs indefinitely

The last row is the honest residual, and it is not specific to this implementation. With the clock pinned and all local state wiped, every anchor available offline (iat, the sealed mark, lastOnlineAt) is either pinned or absent, and the process is indistinguishable from a genuine first launch right after activation. No purely offline check can separate those two cases. What raises the cost in practice:

  1. Persist nextMark to several locations. App data, an OS keychain or registry entry, and a file that does not look like licensing state. “Wipe it every launch” then requires finding all of them first.
  2. Keep maxOfflineDays short (7 to 14). It does not stop the frozen-clock attacker, but it forces every machine with real network access to check in, which is what lets a revocation actually land.
  3. Shorten the lease server-side. A shorter lease cuts the honest never-reconnecting window for every newly issued token with no client release. Ask the service operator; it applies to lease-policy products only.
  4. Act on mustReValidateOnline. It is set when the clock state is untrusted, the lease is in grace, or there is no persisted mark, in other words whenever a recent check-in cannot be proven.

Occasionally (on launch if online, or weekly), refresh the token:

const r = await license.heartbeat({ key, fingerprint, productId: BAKED_PRODUCT_ID });
if (r.ok) {
saveToken(r.token); // new token, lease extended
saveLastOnlineAt(nowSec); // feeds evaluateOffline's maxOfflineDays deadline
} else if (r.error === "revoked" || r.error === "expired") {
// A DEFINITIVE server verdict, not a network failure. Discard the stored token now rather
// than riding out the remaining lease and grace (up to 44 days on defaults, which is exactly
// the refund-abuse window).
clearToken();
}

Distinguish a definitive revoked, expired, or blocked from network. On a network failure keep the token and retry later, because a customer with flaky wifi must not be locked out. If you skip heartbeats entirely, the app keeps working until the lease and grace pass, or until maxOfflineDays elapses, whichever comes first.

The token’s signed hb claim carries the cadence the merchant wants for check-in attempts (3 days by default). It is a hint for when to try, not a lever on how long the app may run offline; the lease is the only offline dial.

Compute base64url(HMAC-SHA256(key = appSalt, message = rawMachineId.trim().toLowerCase())). The SDK’s computeFingerprint(rawMachineId, appSalt) does exactly this. Take rawMachineId from a stable per-OS source:

OS Source
Windows Registry HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
macOS IOPlatformUUID (IOKit), or ioreg -rd1 -c IOPlatformExpertDevice
Linux /etc/machine-id (or /var/lib/dbus/machine-id)

Keep appSalt constant for a given product: changing it invalidates every existing activation. The raw ID is hashed locally and never transmitted.

If a customer’s hardware changes enough that the fingerprint changes, send the previous token as priorToken along with the new fingerprint when activating. The server replaces the old device row in the same step, so the customer does not see a seat-limit error for their own drifted machine. See POST /v1/activate.

Give users a way to see and free their own devices:

const list = await license.listDevices({ key }); // → devices, no fingerprints
await license.deactivate({ key, fingerprint }); // release THIS machine
await license.deactivate({ key, deviceId }); // release another device by its ID

POST /v1/devices with the caller’s fingerprint marks the matching row current: true, and each row can carry worksUntil, the date that device keeps working offline. Confirm before releasing, and wipe the local token and key after a successful self-release.