Requirements
- Node.js 20 or newer (the SDK uses the global
fetch), or a Node-based runtime such as Electron for your game or launcher. - The
@bindseal/sdkpackage — plain JavaScript ES modules with zero npm dependencies and no native bindings. - A Bindseal product and license on the license server at
https://api.bindseal.com.
JavaScript is the only SDK available today.
1. What you need from the server
Three things:
- Product id — the id of your game on the license server, e.g.
my-game. It is compiled into your build. - Trust anchor — the signing public key and its kid (key id). The anchor is how the client verifies that an activation response really came from the license server. The client never trusts a plain “ok” boolean; it verifies the signature against this key.
- A license key — a
lic_…string, the key a player enters (or your store integration supplies).
The kid is only a short id (a hash of the key) used to pick the right key. Embed the full Ed25519 public key in your build at build time. It can verify a signature but can’t forge one, so nothing secret ships in your game.
2. Construct the client
The SDK’s BindsealClient composes the whole license lifecycle behind one small API over a single post(path, body) transport. The quickest way to stand one up is createClient, which builds the trust store from a plain array of anchors and supplies a fetch-based transport for a baseUrl:
import crypto from "node:crypto"; import { createClient } from "@bindseal/sdk"; // 1. Turn the published anchor (raw Ed25519 public key, hex) into a KeyObject. const SIG_ALG_ED25519 = 0x01; const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); const publicKey = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(PUBKEY_HEX, "hex")]), format: "der", type: "spki", }); // 2. One call: trust store + default HTTPS transport. const client = createClient({ baseUrl: "https://api.bindseal.com", product: "my-game", trustAnchors: [{ kidHex: KID, alg: SIG_ALG_ED25519, publicKey }], fingerprint, // this machine's fingerprint-v1 object (see below) buildId: 1, // only needed if your product uses version gating });
Or construct it directly
If you need to inject a clock, a nonce factory, or a persistent clockStore, build the client yourself. The constructor needs post, product, trustStore and fingerprint:
import { BindsealClient } from "@bindseal/sdk"; // Trust store: kidHex -> { alg, publicKey } const trustStore = new Map([[KID, { alg: SIG_ALG_ED25519, publicKey }]]); // A fetch-based transport. Return parsed JSON for ANY HTTP status (declines and // update_required stay visible to the SDK); return null on a network failure // (so the SDK can fail OPEN on an outage). const post = async (path, body) => { let res; try { res = await fetch("https://api.bindseal.com" + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } catch { return null; } try { return await res.json(); } catch { return null; } }; const client = new BindsealClient({ post, product: "my-game", trustStore, fingerprint, buildId: 1, });
A transport that throws on a 4xx makes a server decline look like a network outage, so the lease would fail open when it should surface the refusal. Return the parsed body for every status, and null only when the request never completed.
The machine fingerprint
Machine-bound licenses need this machine’s fingerprint-v1 object, sent on activation. It is a set of independent, keyed (HMAC) signal digests plus quality metadata — raw hardware identifiers never leave the player’s machine.
const fingerprint = { v: 1, env: "bare_metal", os: "windows", collected_at: "2026-10-18T10:15:30Z", signals: { /* keyed digests, e.g. "os.machine_id", "fw.system_uuid", … */ }, quality: { /* … */ }, };
3. Activate
const status = await client.activate(LICENSE_KEY); // "valid" | "grace" | "expired"
activate() sends the license key, verifies the signed activation response against your embedded anchor, caches the machine id and the lease window, and returns the offline status.
- If the signature doesn’t verify — a forged or man-in-the-middled response — it throws. There is no boolean to patch.
- A refusal (
revoked,suspended,update_required, …) is surfaced verbatim as a thrownBindsealClientError, so your UI can tell the player exactly what happened.update_requiredis an update prompt, not a lockout.
4. Gate a feature
Gate on the signed server response, never on a local flag. There are two shapes.
Lease gate — available on every license
status() is derived offline from the signed lease window:
if (client.status() === "valid") { unlockFullGame(); // the lease is server-signed; skip activation and status() is "inactive" }
A lease gate is still a check that runs on the player’s machine, so a determined cracker can patch around it. It’s the right default for most of a game; for value that must not exist in a cracked copy at all, use a hollow, fused or sealed feature.
Hollow feature — the server computes the value
For a feature whose result must not exist in the client, the server runs the operation and returns a signed capability; the client only consumes its result:
const result = await client.feature({ op: "report.render", scope: ["feature.pro"], input });
If the server declines (unlicensed, revoked, rate-limited, wrong build), there is no result to use — the feature is inert, not bypassed. Hollow operations must be configured on the server for your product; until one is, feature() returns nothing, and you gate on the signed lease instead.
Fused and sealed features (fusedFeature(), sealedSection()) fold a server-supplied operand or key into local computation, so the wrong token yields the wrong output rather than an error to patch out. See the SDK reference.
5. Heartbeat and the offline lease
const status = await client.heartbeat(); // refreshes the lease; propagates revoke/suspend/expiry
The lease is a fail-open window evaluated entirely offline from the cached, signed response:
- valid — within
lease_expires. The game runs; no server needed. - grace — past
lease_expiresbut withingrace_until. Still runs, on borrowed time, so a transient outage never hard-locks a paying player. - expired — past
grace_until. The gated content stops.
heartbeat() on a network failure returns the cached status rather than throwing — the game keeps running on its lease. A server decline (revoked, suspended) does throw, so a real revocation propagates on the next contact.
The window is guarded against a player winding their clock back; a heartbeat with a corrected clock recovers. Use clockRollbackSuspected() to explain an "expired" status caused by a turned-back clock.
For a game that must run offline for long stretches, fetch a signed offline grant once while online with client.fetchOfflineGrant(…), persist it, and reload it at startup with client.loadStoredOfflineGrant(…). See Offline grant.
6. Tamper reporting
The client reports tamper evidence; the server adjudicates. The client never bricks itself on its own evidence:
const { action } = await client.reportTamper({ evidence, report }); // action is the server's verdict (e.g. "observe"); a failed report never locks the client.
See reportTamper() and the POST /v1/adjudicate endpoint.
Honest boundary
These mechanisms make a cracked client yield nothing instead of a bypass for server-backed features, and raise the cost of re-cracking each release. They are not a claim that your game can’t be cracked — given enough time, client-side checks can be patched. Decide per feature which tier is worth it.