Integrate the SDK.
Wire Keysat licenses into your software in under an afternoon. The verifier is pure-function, offline, and ships in five lines. What you do with the result (refuse to start without a license, unlock specific features, just show a "supporter" badge) is your call. The SDK is the primitive; the business model is yours.
Prerequisites
Before you start, you should have:
- A Keysat installation running on your Start9; see Install & setup.
- BTCPay Server connected to Keysat; ditto.
- At least one product defined in the admin UI.
Pick an SDK
Four official SDKs ship today. They are wire-compatible. A license issued by your Keysat verifies identically in any of them. Cross-check fixtures in the daemon repo prove each SDK accepts the same bytes the daemon mints.
# npm npm install @keysat/licensing-client # pnpm pnpm add @keysat/licensing-client
If your language isn’t covered, see Wire format. The format is small and porting takes about an afternoon.
Step 1: Embed your public key
In the admin UI, open Overview and copy the issuer public key from the "Embed your public key" card. (Or fetch it from GET /v1/issuer/public-key.) Paste it into your application’s source code as a compile-time constant.
const ISSUER_PEM = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL -----END PUBLIC KEY-----`;
Embed it. Don’t fetch it. The whole point of offline verification is that your software can’t be tricked by a network-level attacker. If you fetch the public key at runtime, you’re back to trusting a server.
Step 2: Verify a license at startup
Read the user’s license key from wherever you store it (a file in their data directory, the OS keychain, an env var) and verify it on application start. In a server-side app the key arrives per request instead: read it from a header you define (for example X-License-Key) or the session, then verify it the same way.
import { Verifier, PublicKey } from '@keysat/licensing-client'; const verifier = new Verifier( PublicKey.fromPem(ISSUER_PEM) ); // verify() returns the verified license, or THROWS if the key is missing, // malformed, forged, or signed by someone else. Catch it (see step 3). const license = verifier.verify(licenseKeyFromUser); // What you do next is up to YOUR business model. The verified payload // carries the entitlements baked in at issue time. app.licensed = true; app.entitlements = license.payload.entitlements; // string[]
On success, verify() returns a VerifyOk result. There is no valid boolean: an invalid key throws (TS / Python) or returns Err (Rust). See step 3. Field names are camelCase in TS/JS and snake_case in Rust/Python.
| Field | Type | Meaning |
|---|---|---|
productId | string | UUID of the product this license was issued for. |
licenseId | string | UUID of the license; useful for support tickets. |
payload.entitlements | string[] | Feature slugs baked into the signed payload. |
payload.issuedAt | number | Unix seconds at issue time. |
payload.expiresAt | number | Unix seconds; 0 for perpetual. |
payload.isTrial | bool | Set by the policy at issue time. |
payload.isFingerprintBound | bool | True if the key is bound to one machine. |
verify() checks the signature and format, not expiry or revocation. A perpetual license never expires; to reject expired keys offline, compare the payload’s expiresAt to now. Every SDK ships an isExpiredAt/is_expired_at helper for this; TS and Rust also offer a one-call verifyWithTime(key, nowUnixSeconds). Live status (revoked, suspended, seats in use, the policy slug) isn’t in the offline payload; get it from the online validate path below.
Step 3: Handle errors gracefully
Verification can fail for benign reasons (the user hasn’t pasted a license yet) or hostile ones (someone tampered with a license file). Distinguish them in your UX:
import { LicensingError } from '@keysat/licensing-client'; try { const license = verifier.verify(licenseKey); // throws if not valid grantAccess(license); } catch (e) { // Every failure is a LicensingError with a machine-readable .code: // 'bad_signature' (tampered / forged), 'bad_format' or 'bad_encoding' // (garbled input), 'bad_version', 'expired' (only from verifyWithTime). if (e instanceof LicensingError && e.code === 'bad_signature') showTamperWarning(); else if (e instanceof LicensingError) showInputError(); else showGenericError(e); }
Renewals & revocation
Keysat licenses are signed at issue time and do not phone home. If a license is revoked in the admin UI, the existing key continues to verify in your app. That’s the trade-off for offline.
If you need revocation, ship a thin online check that re-validates the key on a cadence (e.g. once a week) against your Keysat’s POST /v1/validate. A revoked license returns ok: false with reason: "revoked":
// Optional. Run on a cadence, ignore network errors. async function checkRevocation(licenseKey: string) { const r = await fetch('https://your-keysat.example/v1/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: licenseKey }), }); if (r.ok) { const j = await r.json(); if (!j.ok && j.reason === 'revoked') disableApp(); } }
You decide the policy. Many indie developers ship no revocation at all. Once a key is sold, it stays valid. That’s perfectly reasonable.
Admin API
The admin UI is a thin shell over a small JSON API. Bearer-auth all requests with your admin API key.
| Method | Path | Use |
|---|---|---|
GET | /v1/products | List products (public). |
POST | /v1/admin/products | Create a product. |
POST | /v1/admin/policies | Create a policy. |
POST | /v1/admin/discount-codes | Create a discount or comp code. |
GET | /v1/admin/licenses | List a product’s licenses; requires ?product_id=<uuid>. |
GET | /v1/admin/licenses/search | Search licenses by buyer_email, nostr_npub, or invoice_id. |
POST | /v1/admin/licenses/<id>/revoke | Revoke a license. |
POST | /v1/admin/webhook-endpoints | Register an outbound webhook. |
GET | /v1/admin/audit | Read audit log. |
POST | /v1/redeem | Redeem a free-license code (public). |
Full schemas for each endpoint live in Wire format & API reference.