Critical bug fixes — code an LLM would copy verbatim: - Wire format §4: clarify FLAG_FINGERPRINT_BOUND = bit 0 (mask 0x01), FLAG_TRIAL = bit 1 (mask 0x02). The doc previously claimed FLAG_TRIAL=1, which is wrong — that's the fingerprint-bound bit. - Trial detection across §7a / §7b / §7c / §14: stop doing `(flags & 1)` manually. TS/Python/Go SDKs pre-parse isTrial / is_trial / IsTrial() on the payload. Rust requires manual math but with the FLAG_TRIAL constant from the crate, not bit 0. - Field name sweep: TS payload field is `licenseUuid` (or top- level `licenseId` on the result root), not `payload.licenseId`. Rust payload's `license_id` is `[u8; 16]` raw bytes — render to hex for display. Updated examples to match each SDK's actual API. - §9a cross-product safety: rewritten end-to-end. The payload carries product UUID (not slug). The old doc told the LLM to assert `payload.product_slug !== MY_SLUG`, which silently passes because the field doesn't exist. New doc covers both correct paths: online via `validate(slug, …)` (daemon resolves slug→UUID), or offline by embedding the operator's product UUID. Stale references / improvements: - §0 Q4: cross-reference for hard-gate flavors corrected to §7d (was pointing at §8 which is entitlement-naming). Added a "soft-gate is the safe default" nudge. - §0 new Q8: ask whether the operator already has an entitlements catalog before drafting the config card. - §7a GitHub fallback: trimmed. SDK repos are public and have `prepare` scripts, so the ssh-vs-https troubleshooting saga isn't needed anymore. - §7d "Mode::Enforce" reference removed — that build-time flag was deprecated. Keysat itself dogfoods soft-gate (always boots, tier caps enforce at create-time). New content for one-shot integration success: - §8 / §11a: hidden_entitlements (v0.2.0:24) explained — buy page filters them out; SDK consumers should too. - §11a "Rendering tier cards": multi-currency formatter (priceCurrency + priceValue), marketing_bullets + marketing_bullets_position, featured_discount auto-apply via the `code` option on startPurchase. - §11a Common mistakes: assuming all prices are in sats; skipping the featured-discount surfacing. - New §15a "Verify your integration with curl": four-command health-check the LLM can run before writing app code. Catches slug typos, missing policies, unreachable daemon early. - §15 Common mistakes: added the product UUID gotcha and the flag bit-math gotcha as explicit entries. LLM-consumer impact: the previous version had three subtle bugs that survived offline signature verification — wrong trial detection on every fingerprint-bound license, missing product isolation across multi-product Keysats, and a wrong PRO-tier default selection. All three failure modes are now flagged or fixed in the doc; an LLM that follows the new doc literally produces correct integration code.
76 KiB
Integrating Keysat licensing into your software
This document is the complete instruction set for adding Keysat-based licensing to any application. It covers Node/TypeScript, Python, and Rust. Hand it to an LLM (or a developer) along with your codebase and ask them to wire it up — they should have everything they need.
How to use this document
Drop KEYSAT_INTEGRATION.md into a fresh session against the target
codebase, then prompt:
Read this
KEYSAT_INTEGRATION.md. Then add Keysat licensing to this codebase. Ask the software creator how they would like to license, monetize and/or gate their software, including how unlicensed software should operate. Use the creator's Keysat public key which can be found in the Keysat admin UI. After wiring, summarize what you changed.
Reading this as an LLM? Your job is to add license-key reading, signature verification, and entitlement-based feature gating to the target application. Before you write any code, ask the software creator the questions in section 0. Then read sections 1–6, then jump to the language section (7a/7b/7c) that matches your target codebase. The "Worked example" in section 14 is the canonical pattern to mimic.
0. Questions to ask the software creator before writing code
Don't write any code until you have answers. The whole licensing model hangs on these:
-
What's the operator's Keysat instance URL? (e.g.
https://licensing.example.com. Used for online validation and the in-app purchase flow.) -
What's the operator's product slug? (Short string the operator chose when creating the product in their Keysat admin. License keys are scoped to this slug.)
-
What's the operator's signing public key? (PEM-formatted Ed25519 public key. Get it from the Keysat admin Overview tab → "Embed your public key" → Copy. The operator pastes it; you embed it.)
-
How should unlicensed users experience the app? Three legitimate patterns; pick whichever fits the operator's business model. None is "wrong."
- Soft gate (safest default if the operator is unsure) — the app runs and provides basic functionality unlicensed; specific paid features return 402 with an "Upgrade to unlock" message. Recommended for free → paid migrations and for freemium products.
- Hard gate — the app downloads freely from the Start9 registry, but won't function without a paid license. The binary is essentially a locked installer until the buyer activates. Common for closed-source paid apps and for open-source apps that the operator chooses to monetize through the registry distribution. See section 7d for the two flavors of hard gating (refuse-to-start vs. activate-screen-only).
- Nag mode — no enforcement; just a "support development" banner when unlicensed. Pure honor system. Useful when the app is fundamentally free-to-use but the operator wants a tip-jar.
Nudge the operator if their answer doesn't match their business reality. Closed-source-paid + nag-mode is incoherent; freemium + hard-gate alienates the existing user base. When in doubt, propose soft-gate — it preserves the user's ability to evaluate the app before paying and gives the operator the simplest implementation.
-
What are the entitlement strings, and what does each unlock? The operator decides; ask them. Common patterns:
["self_host"]for a free tier — "you can run the app, no premium features"["self_host", "export", "ai_features", "team_seats"]for a paid tier["patron"]extra for a vanity supporter tier Document the mapping (entitlement string → feature unlocked) in your integration so the operator can ship the right policies.
-
Where should the license key live on disk at runtime? Default:
/data/license.txtfor server / containerized apps, or~/.config/<your-app>/license.keyfor desktop apps. Operator may override. -
Which pricing tiers exist and roughly what they cost? (Optional for the integration itself, but useful for shaping the "Upgrade" message that shows when an unlicensed user hits a paid feature.)
Two-or-more-tier products unlock a UX option: an in-app tier picker that renders the buyer's options inside the operator's own UI (e.g. on the activation screen) and drives the purchase programmatically through the SDK, instead of redirecting to the externally-hosted
/buy/<slug>page. See section 11a — this is often the strongest fit when the app already has a settings or activation surface where "Choose a plan" feels native. If there's only one tier (or only one paid tier), skip this and use the simpler single-policy flow in section 11.
If the creator doesn't know yet, propose sensible defaults from the ranges above and confirm before coding.
-
Has the operator declared an entitlements catalog on the product yet? (Admin → Products → Edit → "Entitlements catalog".) If yes, the operator already has a typed list of feature slugs + display names + descriptions; ask them to paste it so your gating logic uses the canonical slugs and avoids drift. If no, propose a catalog in your config card (next step) so the operator can paste it into admin in one step — this prevents the operator from inventing a slightly different slug spelling later. See section 8 ("Picking entitlement names") for the catalog mechanics.
-
Compile a config card before writing code. After answering 1–8, produce a short summary the operator can paste into the Keysat admin without re-deriving anything. This is the single highest-leverage step for avoiding "wait, what entitlements did we agree on?" churn later. The card has three parts:
- Product: the slug from question 2.
- Policies: each policy's name and the entitlement set it issues. Treat this as the operator's pricing menu — one policy per tier.
- Behavior matrix: caller state → what happens. Lets the operator sanity-check the gating model (question 4) against the policy set.
Show the card to the operator, get explicit confirmation, then write code. Example for a two-tier hard-gate-flavor-2 freemium app:
Product slug: youtube-summarizer Policies to create in Keysat admin: • Core → entitlements: ["core"] • Pro → entitlements: ["core", "subscriptions", "history", "library"] Entitlement → unlocks: core — past the activation screen; basic summarize subscriptions — channel subscriptions, auto-queue history — saved summary library library — bulk import/export Behavior matrix: no license → 402 license_required everywhere Core license → summarize works; subs/history/library = 402 feature_not_in_tier Pro license → all features availableWithout this card, mid-implementation drift is near-certain — the LLM gates on
library_io, the operator creates a policy withlibrary, and the buyer sees a "feature not in tier" error on a feature they thought they paid for.
0a. How enforcement actually works (online vs offline)
This is the most-asked question every operator hits when they realize they want to revoke a license, downgrade a buyer, or have a recurring sub lapse. Read this section before designing your gating logic; the choice you make here is sticky.
What the buyer's app can enforce offline
These are baked into the signed license key at issuance time. Once issued, they're cryptographically immutable for the life of that key. The buyer can install your app on an air-gapped box and these checks still work, forever:
- Hard expiry. If the operator issued the license with
duration_seconds: 31536000(1 year), the offline verifier rejects it on day 366. No network needed. - Entitlement set. Whatever entitlements were on the policy when the license was signed are what the offline check sees forever. Operator edits to the policy after issuance don't reach this license.
- Trial flag. TRIAL bit in the signed payload, offline detectable.
- Fingerprint binding. If the key was issued bound to machine X's fingerprint, machine Y fails offline verification.
These are tamper-proof because Ed25519 signatures can't be forged without the operator's private key.
What the operator can change only via online enforcement
These mutations live in the operator's licensing-service DB and
never reach the buyer's app unless the app actively calls
/v1/validate:
- Revocation. DB row flips
revoked_at; signed key still verifies offline. - Tier downgrade / upgrade. New entitlements live in the DB; signed key still has the old ones.
- Recurring subscription lapse. Sub goes
past_due→lapsedserver-side. Signed key (which is justexpires_at = now + 30 daysfor monthly subs) keeps verifying offline until its baked expiry. - Seat enforcement beyond per-key fingerprint binding.
The two design dials the operator picks
For each product they sell:
- How short is the baked expiry? Short (e.g. 35 days for a monthly sub) = buyer must come online frequently to refresh; operator retains tight control. Long / perpetual = buyer can stay offline indefinitely; operator gives up most post-sale enforcement.
- Does the buyer's app actually call
validate()? This is YOUR call as the SDK consumer. If the app only doesverifier.verify(key)(offline signature check) and never callsclient.validate(...), no operator-side change can ever reach a buyer who's already activated. If the app callsvalidate()on launch + daily with a sensible cache fallback, operators have near-real-time control.
The two patterns
Pattern A — true perpetual, no take-backs. App does
verifier.verify(key) at launch and trusts whatever the signed
payload says. Buyer pays once, gets entitlements forever, even
if the operator regrets it. Honest sale, like buying a Photoshop
CS6 disk in 2012. Works for: tools the operator is confident they
want to lifetime-license; markets where buyers explicitly value
"buy once, own forever"; software that may need to function
on air-gapped boxes.
Pattern B — perpetual price, online-enforced entitlements.
App calls client.validate(...) periodically (on launch + daily)
and treats the SERVER's entitlement set as authoritative. The
license is "perpetual" in that there's no expiry-driven re-payment,
but enforcement is live. Operator retains downgrade / revoke /
sub-lapse control. Buyer's offline experience is normal as long
as they come online once per cache window. This is what most
"SaaS replacement" products want.
// Pattern A — offline-only
import { Verifier } from '@keysat/licensing-client'
const v = new Verifier(OPERATOR_PUBKEY_PEM)
const ok = v.verify(licenseKey)
if (!ok.valid || !ok.entitlements.includes('core')) refuseToStart()
// Pattern B — online-aware with offline fallback
import { Client } from '@keysat/licensing-client'
const client = new Client(OPERATOR_KEYSAT_URL)
const result = await client.validate(licenseKey, { productSlug, fingerprint })
if (!result.ok) refuseToStart()
// result.entitlements is the LIVE set from the server
// On network failure, fall back to verifier.verify() with a
// cache TTL appropriate to your business (e.g. 7 days).
Operator-side implication
Your pricing/enforcement model has to match the offline-vs-online tradeoff:
- Perpetual licenses with Pattern A: you give up post-sale control. Honest sale. Refund-if-buyer-asks model.
- Perpetual licenses with Pattern B: full operator control, but the app has to be online periodically to bite. Buyers who go fully offline forever can't be touched.
- Recurring subs: NEED short baked-in expiries (1-2 cycles'
worth) plus working
/v1/validateintegration. Otherwise lapsing is unenforceable. - Free trial converting to paid: bake
expires_at = trial_endso the trial expires offline, then renewal flow extends it on payment.
What this means for the tier-upgrade feature (section 11a)
The whole tier-upgrade flow only has teeth if buyers' apps are
calling validate(). For a buyer using Pattern A who paid for
Patron and the operator later downgrades them: nothing happens
until they come online. Same constraint going the other way:
a Pattern A buyer's app wouldn't see new entitlements after an
upgrade until next online call.
This isn't a Keysat-specific limitation — it's a property of any license model that doesn't require always-on phone-home. Keysat deliberately doesn't. That's a feature, not a bug; but you, the SDK consumer, need to decide which pattern your app implements based on the operator's business model.
Keysat dogfoods Pattern B
The Keysat daemon itself uses Pattern B for its own self-license:
verifies the on-disk LIC1 key at boot (Pattern A signature check),
THEN refreshes entitlements from the local DB hourly + on-demand
via POST /v1/admin/self-license/refresh (Pattern B online
component). This is the same pattern you'd implement in any
"perpetual price, live entitlements" app. See
license_self::refresh_self_tier_from_db for reference.
1. What Keysat does, in one paragraph
Keysat lets independent software creators sell their work on their own
terms. The operator (the creator) runs a Keysat instance — typically on
a Start9 box — and Keysat handles the buy page, the Bitcoin payment via
BTCPay, and issuing each buyer a signed license key in LIC1-…-… form.
Your software's job is to read that key from somewhere on disk
(a file, an env var, a config setting) and verify its signature against
the operator's public key. What happens after verification is up to the
creator: maybe the app refuses to function without a license (one-time
purchase model), maybe specific features unlock (free + paid tiers),
maybe nothing changes and the verified license is just used to show a
"thanks for supporting development" badge. You never talk to a Keysat
server at runtime unless you want to — verification is offline, fast
(~1ms), and doesn't depend on the network.
2. The whole integration in 30 seconds
1. Install the Keysat SDK in your language.
2. Embed the operator's PUBLIC key into your app at build time.
3. On startup, read the license key from disk; verify it; populate an
`entitlements` set.
4. Throughout your code, gate paid features with `if entitlements.has("X")`.
5. (Optional) On a timer, also call /v1/validate to catch revocations.
Everything else is polish.
3. Prerequisites — three things you need from the operator
-
A Keysat instance reachable on the public internet. Typically something like
https://licensing.example.com. The operator already has this; you don't need to install one. -
A product slug the operator created in their Keysat. This is a short string (
acme-paint-pro,myapp, etc.). Licenses issued for one slug won't validate against another — this is intentional and stops a customer from buying a cheap product and using its key to unlock an expensive one. -
The operator's signing public key in PEM form. This is what you embed in source. Get it from:
- The admin Overview tab → "Embed your public key" tip card → Copy
- Or
curl https://licensing.example.com/v1/issuer/public-key | jq -r .public_key_pem
The PEM is non-secret — anyone with the public key can verify licenses but not mint them. It's safe to commit to source control and ship in your binary.
-
The public buy URL for your product. Each product on a Keysat instance has a buyer-facing page at
<keysat-base-url>/buy/<product-slug>. Use this for "Buy a key" / "Upgrade to Pro" links in your app's activation screen, settings, and per-feature upsell tiles. Compute it from the same constants you've already embedded — don't hard-code a separate URL that can drift:const buyUrl = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/buy/${PRODUCT_SLUG}`The simpler "link to a buy page" path (this URL) is fine for most apps. If you want a more integrated checkout, see section 11 for
client.startPurchase().
4. The wire format you'll be reading
License keys look like:
LIC1-AIAMCWOS5JVHSQE2UMP6PNKXODHSIPHM5O3XQQ2J6CE4XV6WVNMA3BIAAAAA…
A LIC1- prefix, then two base32 segments separated by -. The first
segment is a binary payload; the second is an Ed25519 signature over
the payload. The SDK parses and verifies in one call. You should never
need to handle the encoding manually.
The signed payload contains:
product_id(UUID; not a slug — see §9a for cross-product checks)license_id(UUID — useful for logging; never log the full key)issued_at(Unix seconds)expires_at(Unix seconds; 0 means perpetual)flags(bitfield: bit 0 =FLAG_FINGERPRINT_BOUND(mask0x01); bit 1 =FLAG_TRIAL(mask0x02))entitlements: string[]— this is the array you gate features onfingerprint_hash(32 bytes; for online machine-binding)
Your software reads entitlements and decides what to unlock. Do not
do flag bit arithmetic yourself — every SDK pre-parses the flags into
boolean fields on the payload (isTrial / is_trial, isFingerprintBound
/ is_fingerprint_bound). Use those.
5. Where to read the license from
There's no one-size-fits-all answer; pick one based on how your users interact with your app. Recommended order:
-
A file in the user's data directory. On Linux this is typically
~/.config/<your-app>/license.key, or/data/license.txtfor server software running in a container. The file contains exactly one line: the rawLIC1-…string. This is the most common pattern. -
An environment variable like
MYAPP_LICENSE_KEY. Useful for server-side software, CLIs, Docker Compose, and systemd. Easy to set, but users forget they set it and lose track. -
A "paste your license key" UI in your app's settings, with the value persisted to localStorage / OS keychain / your own config. Most familiar to users coming from commercial software.
-
Multiple of the above. A common pattern is: "env var first, then file, then UI prompt." All three give you a license string either way; the SDK doesn't care where it came from.
For Start9 packages: there's a activate-license-template that wires this up for you using StartOS Actions and the package store. Copy that template, replace the slug, and you've got Pattern 1 + a StartOS Actions UI for buyers to paste keys into.
6. The canonical integration pattern
Every integration follows the same shape regardless of language and regardless of which enforcement model from question 4 the operator picked. The verify-once-at-startup primitive is the same; what you do with the result is what changes.
on startup:
raw_key = read_license_string() # file, env, or UI value
license_state = {state: 'unlicensed', entitlements: []}
if raw_key is not None:
result = verify(raw_key, ISSUER_PEM) # SDK call
if result.is_valid:
license_state = {
state: 'licensed',
entitlements: result.entitlements,
license_id: result.license_id,
expires_at: result.expires_at,
}
else:
log("license rejected: " + result.reason)
# Then — depending on the operator's chosen model:
#
# HARD GATE : if not licensed, exit (Flavor 1) or block all
# business endpoints (Flavor 2). See section 7d.
#
# SOFT GATE : run normally; specific feature handlers consult
# license_state.entitlements before unlocking.
# See section 7a/7b/7c.
#
# NAG MODE : run normally; show a "support development" banner
# in the UI when license_state.state != 'licensed'.
The verify-and-populate-state step is identical for all three models. The doc is structured the same way: section 7 covers the verify primitive in each language; section 7d covers the hard-gate enforcement flavors; the worked examples in section 14 show soft-gate; the patterns are mix-and-match.
One universal rule across all three models: never hard-fail on
network errors during the optional online validate() call (section 9).
That's separate from refusing to start when no license is present — which
is fine for hard-gate Flavor 1. The thing to avoid is making your app's
uptime depend on the operator's licensing server being reachable.
Don't forget background workers. HTTP middleware gates only catch incoming requests. If you have in-process timers, schedulers, queue consumers, or other background jobs that exercise gated features, add an explicit early-return at the top of each one:
async function checkSubscriptionsBackground() {
if (!LIC.entitlements.has("subscriptions")) return // skip silently
// … existing work
}
Otherwise an unlicensed (or insufficient-tier) instance will keep doing work the buyer didn't pay for — wasting bandwidth, API quota, and server CPU, and producing stale state in the UI when entitlements are later restored. This bites people because the server returns 402 to direct callers but the timer keeps humming along.
7. Language-specific implementations
7a. TypeScript / Node
Install (preferred, once published):
npm install @keysat/licensing-client
GitHub fallback (the npm package is pending publication; the GitHub repo is public and installable directly):
// package.json
"dependencies": {
"@keysat/licensing-client": "git+https://github.com/keysat-xyz/keysat-client-ts.git"
}
Use the explicit git+https:// form (not the github:user/repo shorthand),
which avoids the ssh-vs-https resolution drift that bites hermetic build
environments. The SDK's prepare script builds dist/ automatically on
git install, so no extra steps are needed.
Embed the public key. The simplest way is to commit the PEM file
to your repo at assets/issuer.pub and import it as a raw string:
// in your bundler config (Vite shown)
import issuerPem from './assets/issuer.pub?raw'
Or in plain Node:
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
const issuerPem = readFileSync(path.join(__dirname, 'assets/issuer.pub'), 'utf8')
Verify on startup:
import { Verifier, PublicKey } from '@keysat/licensing-client'
import { readFileSync } from 'node:fs'
const PRODUCT_SLUG = '<your-product-slug>'
const LICENSE_PATH = process.env.MYAPP_LICENSE_KEY_PATH || '/data/license.txt'
function readLicenseKey(): string | null {
if (process.env.MYAPP_LICENSE_KEY) return process.env.MYAPP_LICENSE_KEY.trim()
try { return readFileSync(LICENSE_PATH, 'utf8').trim() }
catch { return null }
}
const verifier = new Verifier(PublicKey.fromPem(issuerPem))
export interface LicenseState {
state: 'licensed' | 'unlicensed' | 'invalid'
reason?: string
licenseId?: string
entitlements: Set<string>
expiresAt?: Date
isTrial?: boolean
}
export function checkLicense(): LicenseState {
const raw = readLicenseKey()
if (!raw) return { state: 'unlicensed', entitlements: new Set() }
try {
const ok = verifier.verify(raw)
// For cross-product safety, you need to assert the payload's
// product matches what your app expects. The payload carries a
// PRODUCT UUID, not a slug — see §9a for the correct check
// (online via `client.validate(key, slug, fp)`, or offline by
// comparing `payload.productUuid` against the operator-provided
// product UUID constant).
return {
state: 'licensed',
licenseId: ok.licenseId, // top-level shortcut on the VerifyOk result
entitlements: new Set(ok.payload.entitlements || []),
expiresAt: ok.payload.expiresAt
? new Date(ok.payload.expiresAt * 1000)
: undefined,
isTrial: ok.payload.isTrial, // pre-parsed by the SDK — don't bit-math
}
} catch (e: any) {
return { state: 'invalid', reason: e.message, entitlements: new Set() }
}
}
Use the state object wherever a feature is gated:
const lic = checkLicense()
console.log(`[license] state=${lic.state} entitlements=[${[...lic.entitlements].join(',')}]`)
// In an Express route:
app.post('/api/export', (req, res) => {
if (!lic.entitlements.has('export')) {
return res.status(402).json({
error: 'feature_not_in_tier',
message: 'Export requires a paid license. See <upgrade_url>.',
})
}
// ... existing export logic
})
7b. Python
Install (preferred, once published):
pip install keysat-licensing-client
GitHub fallback (if the PyPI package isn't published yet). The
keysat-xyz/keysat-client-python repo must be public on GitHub
for this to work in clean environments:
pip install git+https://github.com/keysat-xyz/keysat-client-python.git
(Python's pip-from-git path is simpler than npm's — no separate build step is required since pure-Python packages are installable from source.)
Embed the public key at a path your code can read:
# myapp/license.py
from pathlib import Path
ISSUER_PEM = (Path(__file__).parent / 'assets' / 'issuer.pub').read_text()
Verify on startup:
# myapp/license.py
import os
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
from keysat_licensing_client import Verifier
PRODUCT_SLUG = '<your-product-slug>'
LICENSE_PATH = os.environ.get('MYAPP_LICENSE_KEY_PATH', '/data/license.txt')
ISSUER_PEM = (Path(__file__).parent / 'assets' / 'issuer.pub').read_text()
_verifier = Verifier.from_pem(ISSUER_PEM)
@dataclass
class LicenseState:
state: str # 'licensed' | 'unlicensed' | 'invalid'
reason: Optional[str] = None
license_id: Optional[str] = None
entitlements: set = field(default_factory=set)
expires_at: Optional[datetime] = None
is_trial: bool = False
def _read_license_key() -> Optional[str]:
if env := os.environ.get('MYAPP_LICENSE_KEY'):
return env.strip()
try:
return Path(LICENSE_PATH).read_text().strip()
except (FileNotFoundError, PermissionError):
return None
def check_license() -> LicenseState:
raw = _read_license_key()
if not raw:
return LicenseState(state='unlicensed')
try:
ok = _verifier.verify(raw)
return LicenseState(
state='licensed',
license_id=str(ok.license_id), # top-level shortcut on VerifyOk
entitlements=set(ok.entitlements or []),
expires_at=datetime.fromtimestamp(ok.expires_at)
if ok.expires_at else None,
is_trial=ok.is_trial, # pre-parsed by the SDK; don't bit-math
)
except Exception as e:
return LicenseState(state='invalid', reason=str(e))
Use it:
# myapp/server.py
from .license import check_license
LIC = check_license()
print(f'[license] state={LIC.state} entitlements={LIC.entitlements}')
@app.post('/api/export')
def export_endpoint():
if 'export' not in LIC.entitlements:
abort(402, description={
'error': 'feature_not_in_tier',
'message': 'Export requires a paid license.',
})
# ... do the thing
7c. Rust
Install (preferred, once published):
# Cargo.toml
[dependencies]
keysat-licensing-client = "0.1"
Git fallback (if not on crates.io yet). The
keysat-xyz/keysat-client-rust repo must be public on GitHub:
keysat-licensing-client = { git = "https://github.com/keysat-xyz/keysat-client-rust.git" }
Cargo builds from source, so no separate build step is required.
Embed the public key:
const ISSUER_PEM: &str = include_str!("../assets/issuer.pub");
Verify on startup:
// src/license.rs
use keysat_licensing_client::{Verifier, PublicKeyPem, FLAG_TRIAL};
use std::collections::HashSet;
use std::path::PathBuf;
pub const PRODUCT_SLUG: &str = "<your-product-slug>";
pub const ISSUER_PEM: &str = include_str!("../assets/issuer.pub");
#[derive(Debug, Clone)]
pub struct LicenseState {
pub state: &'static str, // "licensed" | "unlicensed" | "invalid"
pub reason: Option<String>,
pub license_id: Option<String>,
pub entitlements: HashSet<String>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub is_trial: bool,
}
impl Default for LicenseState {
fn default() -> Self {
Self {
state: "unlicensed",
reason: None,
license_id: None,
entitlements: HashSet::new(),
expires_at: None,
is_trial: false,
}
}
}
fn read_license_key() -> Option<String> {
if let Ok(s) = std::env::var("MYAPP_LICENSE_KEY") {
let s = s.trim().to_string();
if !s.is_empty() { return Some(s) }
}
let path = std::env::var("MYAPP_LICENSE_KEY_PATH")
.unwrap_or_else(|_| "/data/license.txt".to_string());
std::fs::read_to_string(PathBuf::from(path))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn check_license() -> LicenseState {
let raw = match read_license_key() {
Some(s) => s,
None => return LicenseState::default(),
};
let pubkey = match PublicKeyPem::from_str(ISSUER_PEM) {
Ok(k) => k,
Err(e) => return LicenseState {
state: "invalid", reason: Some(format!("bad pubkey embedded: {e}")),
..Default::default()
},
};
let verifier = Verifier::new(pubkey);
match verifier.verify(&raw) {
Ok(ok) => LicenseState {
state: "licensed",
// license_id is a [u8; 16] in the Rust SDK — render to hex.
license_id: Some(hex::encode(ok.payload.license_id)),
entitlements: ok.payload.entitlements.into_iter().collect(),
expires_at: if ok.payload.expires_at == 0 {
None
} else {
chrono::DateTime::from_timestamp(ok.payload.expires_at, 0)
},
// Use the FLAG_TRIAL constant — it's bit 1 (mask 0x02), NOT 0x01.
// The Rust SDK leaves flag parsing to the caller.
is_trial: (ok.payload.flags & FLAG_TRIAL) != 0,
..Default::default()
},
Err(e) => LicenseState {
state: "invalid", reason: Some(e.to_string()),
..Default::default()
},
}
}
Use it:
let lic = license::check_license();
tracing::info!(state = lic.state, entitlements = ?lic.entitlements, "license loaded");
// At a feature gate:
if !lic.entitlements.contains("export") {
return Err(MyError::PaymentRequired(
"Export requires a paid license.".into()
));
}
7d. Hard-gate patterns — "the app doesn't function without a license"
If the operator chose hard gate in the section-0 questions (binary freely downloadable, but locked until activated), use one of these two flavors instead of the entitlements-as-feature-flags pattern above. The verifier helpers from 7a / 7b / 7c are still the right primitive — the difference is what you do with the result.
Flavor 1: Refuse to start. The daemon exits at boot with a clear log line if there's no valid license. StartOS will show the service as crashing — the operator's README needs to tell buyers "install the license first via Actions → Set license, then start the service."
// TypeScript / Node
const lic = checkLicense()
if (lic.state !== 'licensed') {
console.error(`[license] not licensed (${lic.state}): ${lic.reason || ''}`)
console.error(`[license] paste a license key into ${LICENSE_PATH} via the StartOS "Set license" action, then restart.`)
process.exit(1)
}
# Python
lic = check_license()
if lic.state != 'licensed':
log.error(f'[license] not licensed ({lic.state}): {lic.reason or ""}')
log.error(f'[license] paste a license key, then restart.')
raise SystemExit(1)
// Rust
let lic = license::check_license();
if lic.state != "licensed" {
eprintln!("[license] not licensed ({}): {}", lic.state, lic.reason.unwrap_or_default());
eprintln!("[license] paste a license key, then restart.");
std::process::exit(1);
}
This is the most aggressive option. Use when (a) the app is closed-source and there's no "free version" of the binary anyone could compile, and (b) the operator is OK with StartOS surfacing the service as unhealthy until activated.
Flavor 2: Run, but block all real work behind an "Activate" screen. The daemon starts normally, but every business endpoint returns 402 until a license is activated. Only the activation endpoint(s) and a status endpoint are open. Buyers see a clean "paste your license to get started" UI on first run; StartOS shows the service as healthy. Generally a better buyer experience than Flavor 1.
// TypeScript / Express — middleware that gates everything except
// the activation paths.
const ACTIVATION_PATHS = new Set([
'/api/license-status', // for the frontend to render activation UI
'/api/activate', // accepts a pasted license key, writes to file, refreshes state
'/healthz', // for StartOS / orchestration
])
let LIC = checkLicense() // mutable; refresh after activation
app.use((req, res, next) => {
if (ACTIVATION_PATHS.has(req.path)) return next()
if (LIC.state !== 'licensed') {
return res.status(402).json({
error: 'license_required',
message: 'This service requires a Keysat license to function.',
activate_url: '/activate', // your frontend's activation page
state: LIC.state,
reason: LIC.reason,
})
}
next()
})
// Activation endpoint — accepts a pasted key, writes it, re-checks.
app.post('/api/activate', express.json(), (req, res) => {
const key = (req.body.license_key || '').trim()
if (!key.startsWith('LIC1-')) {
return res.status(400).json({ error: 'bad_format', message: 'Expected a LIC1-… key.' })
}
fs.writeFileSync(LICENSE_PATH, key + '\n')
LIC = checkLicense()
if (LIC.state === 'licensed') {
return res.json({ ok: true, state: 'licensed', entitlements: [...LIC.entitlements] })
}
return res.status(400).json({ error: 'invalid', state: LIC.state, reason: LIC.reason })
})
# Python / Flask — same idea
ACTIVATION_PATHS = {'/api/license-status', '/api/activate', '/healthz'}
LIC = check_license() # module-level; reload after activation
@app.before_request
def license_gate():
if request.path in ACTIVATION_PATHS:
return None
if LIC.state != 'licensed':
return jsonify({
'error': 'license_required',
'message': 'This service requires a Keysat license to function.',
'state': LIC.state,
'reason': LIC.reason,
}), 402
@app.post('/api/activate')
def activate():
global LIC
key = (request.json or {}).get('license_key', '').strip()
if not key.startswith('LIC1-'):
return {'error': 'bad_format'}, 400
Path(LICENSE_PATH).write_text(key + '\n')
LIC = check_license()
if LIC.state == 'licensed':
return {'ok': True, 'entitlements': sorted(LIC.entitlements)}
return {'error': 'invalid', 'state': LIC.state, 'reason': LIC.reason}, 400
// Rust / axum — same idea: a middleware layer that guards all admin
// routes, plus an /api/activate endpoint that accepts a key and updates
// the in-memory state.
//
// Sketch (full impl follows the existing axum middleware pattern):
// Router::new()
// .route("/api/license-status", get(license_status))
// .route("/api/activate", post(activate))
// .route("/healthz", get(healthz))
// .nest("/api", api_routes())
// .layer(axum::middleware::from_fn_with_state(state.clone(), license_gate))
//
// Inside `license_gate`, return 402 unless the request path is in
// ACTIVATION_PATHS or `state.license.read().await.state == "licensed"`.
How does Keysat itself handle this? Keysat dogfoods the soft-gate
pattern: missing or invalid licenses log a warning and the daemon
starts in Tier::Unlicensed (the Creator-tier caps apply). The admin
UI renders as Creator-tier with an upgrade CTA; product / policy / code
creation endpoints return 402 once the tier caps are hit (see
api/tier.rs).
There's no KEYSAT_LICENSE_ENFORCE build flag — that was deprecated in
favor of always-permissive boot + tier-cap enforcement at create-time.
The pattern is a good reference for soft-gate or hard-gate-Flavor-2 in
your own app: never block boot; gate work on entitlements.
7e. Packaging gotchas — Docker, s9pk, hermetic builds
Most non-trivial integrations end up packaged in Docker (Start9 s9pk, generic container deploys, CI-built images). The following gotchas together account for ~80% of the "it works locally but the build fails" failure mode:
1. Slim base images don't ship git, ssh, or ca-certificates.
node:20-slim, python:3.11-slim, etc. are intentionally minimal.
If you have a git-URL dependency (e.g. the GitHub fallback above),
you'll need at least these in the builder stage:
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Without git: npm errors with spawn git ENOENT when resolving the
dependency. Without ca-certificates: HTTPS clones fail with
SSL certificate problem: unable to get local issuer certificate.
2. npm's git resolver tries ssh:// for github.com URLs first.
Even if your package.json spec and package-lock.json resolved
both say git+https://, npm internally tries SSH first when the host
is github.com. In a container with no SSH client or key, this fails.
Force git to silently rewrite SSH URLs to HTTPS:
RUN git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/" \
&& git config --global --add url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global --add url."https://github.com/".insteadOf "git://github.com/"
The --add flag matters — without it, each subsequent invocation
overwrites the previous one (they share a key) and only the last
rewrite is active.
3. Don't forget to COPY your new license module. If your
Dockerfile lists individual server files explicitly:
COPY server/package.json ./server/
COPY server/index.js ./server/
COPY public/ ./public/
COPY assets/ ./assets/
…the build will succeed, the image will start, and then crash at
runtime with Cannot find module './license.js'. Add a line for the
license module:
COPY server/license.js ./server/ # ← easy to miss
This is the single most common "package builds, container won't boot" failure when retro-fitting licensing into an existing app.
4. Make's incremental rebuild can mask uncommitted changes. s9pk
build chains often look like make x86 → start-cli s9pk pack → docker build. Make may decide nothing's newer than the existing .s9pk
because its dependencies typically include .git/index (which only
updates on git add). Symptom: you change a source file, rebuild,
get an instant "✅ Build Complete!" with the same package as before.
Either stage your changes (git add -A) so .git/index updates, or
delete the existing .s9pk to force a rebuild:
rm myapp_x86_64.s9pk && make x86
5. The --ignore-scripts flag will skip the SDK's prepare build.
If your Dockerfile uses npm ci --ignore-scripts (a common security
hardening), the SDK won't build its dist/ and you'll hit the
"Cannot find module" runtime error from §7a. Either drop
--ignore-scripts for the builder stage, or pre-build the SDK
elsewhere and vendor dist/ in.
7f. Frontend integration for hard-gate Flavor 2
If you picked hard-gate Flavor 2 (server starts, business endpoints return 402 until activated), the frontend is half the work — otherwise unlicensed users see a sea of fetch errors instead of a clean activation screen. The pattern below is framework-agnostic and works in vanilla JS, React, Vue, etc.
Step 1: Fetch license-status before any other API call. It's the prerequisite for deciding what to render.
async function loadLicenseStatus() {
const r = await fetch("/api/license-status")
return r.json() // { state, entitlements, productSlug, keysatBaseUrl, … }
}
Step 2: Render the activation screen as a top-level guard. If
state !== "licensed" (or the core entitlement is missing), replace
the entire app body with the activation card. Don't render the normal
UI underneath — every API call would 402 anyway, producing visible
broken state.
if (lic.state !== "licensed" || !lic.entitlements.includes("core")) {
return <ActivationScreen lic={lic} onActivate={key => activate(key)} />
}
return <App />
Step 3: The activation card needs four things:
- A
<textarea>for pasting the LIC1-... key (use a textarea, not an input — keys are 100+ chars and users will copy-paste with whitespace) - An "Activate" button that POSTs to
/api/license/activatewith{license_key: <pasted>}and refreshes state on success - Distinct error messages for each
reasoncode (see §12), not a generic "activation failed" - A "Buy a key" link to
${keysatBaseUrl}/buy/${productSlug}(see §3)
Optional — embed the tier picker directly in the activation card.
For multi-tier products, instead of (or in addition to) the "Buy a
key" link, render an inline tier picker that lets the buyer pay
without leaving your app. Calls
Client.listPublicPolicies(productSlug) to render the tier list and
Client.startPurchase(productSlug, { policySlug }) to drive the
checkout. The full pattern, including the architecture diagram and
common mistakes, is in section 11a. This is the pattern Recap
ships in their activation screen.
Step 4: Gate Pro features in the UI, not just the server. The server returns 402 for missing entitlements, but unless the frontend also checks, users see ghost UI for features they can't use:
{lic.entitlements.includes("subscriptions")
? <SubscriptionsPanel />
: <ProUpsell feature="subscriptions" buyUrl={buyUrl} />}
Each ProUpsell should explain what they'd unlock, not just "Pro
feature." The server's 402 response includes a message field with a
sentence-long description — surface it.
Step 5: Add a license block to settings. Buyers want to see what
tier they're on, when it expires, and have a way to remove the key.
Hit /api/license-status for state, render a colored badge per tier,
and expose a "Deactivate" button that POSTs to
/api/license/deactivate.
Step 6: Respond to entitlement changes without a reload. After activation, re-fetch any data your app skipped at boot (history, subscriptions, etc.) — the user just unlocked them. After deactivation, clear it from in-memory state so the previous tier's data doesn't leak through the activation screen.
Reference shape your /api/license-status should return so the
frontend has everything it needs without extra round-trips:
{
"state": "licensed",
"reason": null,
"licenseId": "abc123…",
"entitlements": ["core", "subscriptions", "history", "library"],
"expiresAt": "2027-05-01T00:00:00Z",
"isTrial": false,
"productSlug": "youtube-summarizer",
"keysatBaseUrl": "https://licensing.example.com"
}
productSlug and keysatBaseUrl aren't strictly part of license
state — they're there so the frontend can construct the /buy/<slug>
URL without hard-coding it. Ship them in the response.
8. Picking entitlement names
Entitlement strings are arbitrary; they're whatever the operator put on the policy when issuing the license. Common conventions:
- Feature flags:
export,ai_summaries,team_seats,recurring_billing,card_payments - Capability tiers:
unlimited_products,unlimited_seats,priority_support - Branded markers:
patron(no real feature, just a badge)
Pick names that are stable, lowercase, snake-case, descriptive. Document
your chosen entitlement names in your README so operators / customers
know what they're buying. Treat them like API contract — once you ship
a feature gated on "export", you can't rename to "file_export" without
breaking existing licenses.
The operator can use whatever set they want when creating policies; your app only needs to know the names of features it gates on. Operators selling tiered plans typically have:
- A free / Creator tier with one entitlement (
self_hostor similar) - A pro / paid tier with several (
unlimited_*, premium features) - Optional Patron / supporter tier with all of Pro plus a
patronbadge
Entitlements catalog (v0.2.0:8+)
Operators can declare a closed list of entitlements per product in admin (Products → Edit → "Entitlements catalog"). Each entry has three fields:
slug name description
core Core Past the activation screen, basic features.
ai_summaries AI summaries Auto-generate per-video summaries with GPT.
library_io Library I/O Bulk import/export of saved summaries.
Once a catalog exists for a product, two things change:
- The policy editor switches from a free-text textarea to a click-to-toggle bubble picker that only offers entitlements from the catalog. The daemon enforces this at write time too (closed list).
- The buy page renders display names + descriptions instead of
raw slugs. Buyers see "AI summaries" with the description as a
hover tooltip, never the underscore-laden
ai_summaries.
For your SDK integration, the catalog comes back on
GET /v1/products/<slug>/policies (and equivalently
Client.listPublicPolicies() in all four SDKs):
const { product, policies } = await client.listPublicPolicies(SLUG)
// product.entitlementsCatalog is EntitlementDef[]:
// [{ slug: 'ai_summaries', name: 'AI summaries', description: '...' }, ...]
//
// Use it to render an in-app tier picker that shows the same human-
// readable names the buy page does:
function entitlementLabel(slug: string): string {
const def = product.entitlementsCatalog.find((e) => e.slug === slug)
return def?.name || slug.replace(/_/g, ' ')
}
If the operator hasn't defined a catalog (free-text mode), the array is empty and you fall back to rendering the raw slugs — or replacing underscores with spaces yourself for a quick polish.
Catalog stability rule: once you ship gating logic that checks
for entitlement "export", the operator's catalog and policy
references have to stay using "export". Renaming the slug breaks
existing licenses (which carry the old slug in their signed
payload). Adding NEW entitlement slugs to the catalog is fine —
just not renaming or deleting ones that licenses already reference.
9. Online validation (optional, recommended)
Offline verify proves the key was signed by the right operator. Online validation also catches revocations (operator disabled a key) and enforces fingerprint binding (one license = one machine). Use both: offline at boot, online on a timer.
// TypeScript
import { Client } from '@keysat/licensing-client'
const client = new Client('https://licensing.example.com')
async function onlineCheck(licenseKey: string, machineFingerprint: string) {
try {
const r = await client.validate(licenseKey, PRODUCT_SLUG, machineFingerprint)
if (!r.ok) {
// r.reason is one of: 'revoked' | 'fingerprint_mismatch' |
// 'not_found' | 'bad_signature' | 'product_mismatch'
console.warn('license rejected:', r.reason)
// → React in your UI; don't hard-crash on this.
}
} catch {
// Network errors → "status unknown". Don't block the user.
}
}
# Python
from keysat_licensing_client import Client
client = Client('https://licensing.example.com')
try:
r = client.validate(license_key, PRODUCT_SLUG, machine_fingerprint)
if not r.ok:
log.warning(f'license rejected: {r.reason}')
except Exception:
pass # network error → status unknown
// Rust (with `online` feature)
let client = keysat_licensing_client::online::Client::new("https://licensing.example.com")?;
match client.validate(&key, Some(PRODUCT_SLUG), Some(&fp)).await {
Ok(r) if r.ok => { /* fine */ }
Ok(r) => log::warn!("rejected: {:?}", r.reason),
Err(_) => { /* network error, don't punish */ }
}
Cadence: once at startup after the offline check succeeds, then on a timer (hourly is plenty). Once-per-feature-call is too aggressive and beats up the operator's server.
Critical: never refuse to start if validate() throws. Network
errors must degrade to "I can't tell, assume the user is fine" — not
"app refuses to launch." Otherwise your app's uptime depends on the
operator's licensing server being up.
9a. Cross-product safety — read this if the operator sells more than one product
Many operators run a single Keysat instance that issues licenses for multiple products (e.g. one Keysat serves both Recap and Notewise). All of those licenses are signed by the same Ed25519 keypair. Without the right check in your app, a license issued for Recap would parse + signature-verify successfully inside Notewise — same public key, valid signature. That would be a real bug, not a theoretical one.
Important: the signed payload carries a product UUID, not the product
slug. Every SDK exposes it as productUuid (TS), product_id (Python /
Rust as a uuid.UUID / [u8; 16]), or ProductID (Go as [16]byte).
There is no product_slug field on the payload. Treat the two paths
differently:
Online path (preferred) — daemon resolves slug → UUID for you
Always pass the product slug to client.validate(...). The daemon
looks up the product by slug, fetches its UUID, and compares against
the UUID baked into the license. If they don't match, you get
reason: 'product_mismatch'. This is the simplest correct check.
const MY_PRODUCT_SLUG = 'recap' // hard-code; matches what the operator picked
const r = await client.validate(licenseKey, MY_PRODUCT_SLUG, machineFingerprint)
if (!r.ok) {
// r.reason === 'product_mismatch' if a Notewise license was presented
reject(r.reason)
return
}
Offline path — ask the operator for the product UUID and compare it
Because the payload only has a UUID, you need the operator's product UUID
(not just the slug) baked into your app to do an offline cross-product
check. Get the UUID from the operator's admin UI (Products → Edit → the
URL or the admin API: GET /v1/admin/products returns each product's id).
Embed it as a constant alongside the slug.
const MY_PRODUCT_SLUG = 'recap'
const MY_PRODUCT_UUID = '11111111-2222-3333-4444-555555555555' // ask the operator
const ok = verifier.verify(licenseKey)
if (ok.payload.productUuid !== MY_PRODUCT_UUID) {
reject('product_mismatch')
return
}
For Python / Rust / Go, compare payload.product_id / payload.ProductID
against the same UUID constant (parsed as uuid.UUID in Python, raw bytes
in Rust / Go).
Why the SDK doesn't auto-reject offline
Verifier.verify is intentionally low-level — it returns the verified
payload and lets the caller decide what to enforce. A multi-product app
might legitimately accept any product the operator signed for; a
per-product app must reject mismatches. Making this opt-in keeps the
SDK honest about what it's checking on your behalf.
Forgetting to check is a silent failure
If you call verifier.verify without asserting the product UUID (or
using the online validate path with the slug), a license from any
of the operator's products will signature-verify and you'll treat it
as valid. There is no warning. Make the check a constant in your app
and assert it on every code path that loads a license.
Single-product instances don't need this
If the operator runs one Keysat per product (most indie setups), you
don't need the offline cross-product check at all — the daemon only
mints licenses for the one product. The online validate(slug, …) call
still catches typos in the slug, so it's worth doing either way.
10. Fingerprint binding (for validate())
When you call client.validate(...), the third argument is a machine
fingerprint. The operator's Keysat binds the first fingerprint it sees
to the license; subsequent calls with a different fingerprint return
reason: 'fingerprint_mismatch'. This is the anti-piracy mechanism.
Compute the fingerprint from something stable across reboots but unique per machine:
| Platform | Source |
|---|---|
| Linux | /etc/machine-id |
| macOS | ioreg -d2 -c IOPlatformExpertDevice → IOPlatformUUID |
| Windows | Registry: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid |
| Fallback | A UUID written into your app's config dir on first launch |
Mix in a per-product salt so fingerprints collected by your app can't be reused against a different operator's licensing service:
const fingerprint = `${PRODUCT_SLUG}|${machineId}`
The SDK hashes this before sending, so the operator's Keysat never sees the raw input.
If a customer legitimately moves devices and hits fingerprint_mismatch,
they should contact the operator. The operator can reset the binding
from their admin dashboard. Don't try to help users bypass this in
your app — it's the protection working as intended.
11. Driving the purchase flow from inside your app (optional)
If your app can open URLs (desktop GUI, CLI that can xdg-open), you
can drive the entire purchase flow from inside without forcing the user
into a separate browser tab.
import { Client } from '@keysat/licensing-client'
import open from 'open'
const client = new Client('https://licensing.example.com')
async function buyLicense(buyerEmail?: string): Promise<string> {
const session = await client.startPurchase(PRODUCT_SLUG, { buyerEmail })
await open(session.checkoutUrl) // BTCPay invoice page
const key = await client.waitForLicense(session.invoiceId, { timeoutMs: 30 * 60_000 })
return key
}
waitForLicense polls /v1/purchase/<id> until the BTCPay invoice
settles and the license is signed. Save the returned key to disk
(/data/license.txt or wherever your app reads from), then re-run
checkLicense().
The simpler alternative: just link to the operator's buy page and let them complete the purchase on the web, then paste the resulting key into your app's settings. Less integrated, less friction to implement.
If the product has two or more public policies (Core/Pro, Free/ Standard/Pro, etc.), see section 11a for the tier-aware flow that lets buyers pick a tier inside your app's own UI.
11a. Tier-aware purchases — in-app tier picker (multi-tier products)
When a product has multiple public policies, the buyer needs to pick
which tier they're paying for before the invoice is created. Section
11's startPurchase(slug, { buyerEmail }) defaults to the product's
"default" policy (or the first active one), which works fine for
single-tier products but always issues a Core license on a Core/Pro
setup — no matter what the buyer wanted.
The fix has two pieces, both supported by the SDK since 0.2.0:
Client.listPublicPolicies(productSlug)— fetches the buyer- visible tier list fromGET /v1/products/<slug>/policies. Public endpoint, no auth. Returns each tier's slug, display name, price (in the product's listed currency's smallest unit — sats for SAT, cents for USD/EUR), entitlements, recurring/trial flags, and the "Most popular" highlight flag. Render this into your tier-picker UI; it'll stay in sync if the operator adds/edits tiers in Keysat admin without you redeploying the app.policySlugfield onstartPurchase's options — when set, the licensing service prices the invoice at that policy'sprice_sats_overrideand the issued license carries that policy's entitlements, duration, max_machines, and trial flag.
When you'd use this
- Multi-tier products where the choice happens in the buyer's app
(activation screen, settings, in-app upgrade banner). Common shape:
freemium app where Free is gated by
coreentitlement and Pro unlockssubscriptions, history, library. - Operators who want to add or rename tiers without forcing an app
update — the picker rebuilds itself off
listPublicPolicies. - Apps that need to write the issued license key directly to disk themselves (e.g. via a backend service, not via copy-paste from the buy page). The SDK delivers the signed key as a string; you write it where you want.
Pattern (TypeScript / web app frontend)
import { Client, PublicPolicy } from '@keysat/licensing-client'
const client = new Client('https://licensing.example.com')
// 1. Fetch tiers — typically on activation screen mount.
const { product, policies } = await client.listPublicPolicies(PRODUCT_SLUG)
// 2. Render `policies` into your tier-picker UI. Each policy carries
// everything you need to display:
function renderTier(p: PublicPolicy) {
return `
<button data-slug="${p.slug}" class="tier ${p.highlighted ? 'popular' : ''}">
<h3>${p.name}</h3>
<p>${p.description}</p>
<div class="price">${formatPrice(p.priceSats, product /* for currency */)}
${p.isRecurring ? '/' + cadence(p.renewalPeriodDays) : ''}</div>
<ul>${p.entitlements.map(e => `<li>${e}</li>`).join('')}</ul>
</button>`
}
// 3. Buyer picks a tier; you call startPurchase with policySlug.
async function buyTier(chosenSlug: string, buyerEmail: string) {
const session = await client.startPurchase(PRODUCT_SLUG, {
policySlug: chosenSlug, // <-- the discriminator
buyerEmail,
redirectUrl: 'https://your-app.example/thank-you',
})
// 4. Open the checkout URL. For desktop apps, `open(session.checkoutUrl)`.
// For web apps, `window.location.href = session.checkoutUrl`.
window.location.href = session.checkoutUrl
// 5. After payment settles, your backend (or the buyer's poll) hits
// /v1/purchase/<invoice_id> and gets the signed license_key.
// Write it to wherever your app reads from. Reload validate.
}
Pattern (other languages — same shape)
# Python
from keysat_licensing_client import Client, StartPurchaseOptions
client = Client('https://licensing.example.com')
tiers = client.list_public_policies(PRODUCT_SLUG)
# render tiers.policies in your UI; user picks "pro"
session = client.start_purchase(PRODUCT_SLUG, StartPurchaseOptions(
policy_slug='pro',
buyer_email='buyer@example.com',
))
# open session.checkout_url; poll on settle
key = client.wait_for_license(session.invoice_id, timeout_s=30*60)
// Rust
use licensing_client::{Client, StartPurchaseOptions};
let client = Client::new("https://licensing.example.com")?;
let tiers = client.list_public_policies(PRODUCT_SLUG).await?;
// render tiers.policies; user picks "pro"
let session = client.start_purchase(PRODUCT_SLUG, &StartPurchaseOptions {
policy_slug: Some("pro"),
buyer_email: Some("buyer@example.com"),
..Default::default()
}).await?;
// open session.checkout_url; poll on settle
// Go
client := keysat.NewClient("https://licensing.example.com", nil)
tiers, _ := client.ListPublicPolicies(ctx, PRODUCT_SLUG)
// render tiers.Policies; user picks "pro"
session, _ := client.StartPurchase(ctx, PRODUCT_SLUG, keysat.StartPurchaseOptions{
PolicySlug: "pro",
BuyerEmail: "buyer@example.com",
})
// open session.CheckoutURL; poll on settle
Rendering tier cards: what to surface
The listPublicPolicies response gives you everything the operator's
buy page renders. To match the buy page experience inside your app:
policy.entitlementsis the granted-and-visible set. The daemon already filtered out any entries the operator markedmetadata.hidden_entitlementsfor that tier (a feature shipped in v0.2.0:24 — operators use it for "Everything in Basic, plus:" marketing where they don't want to repeat already-implied items on higher tiers). You don't need to filter further; just render.policy.marketingBullets(camelCase in TS,marketing_bulletsin Python / Rust / Go) is operator-authored copy — short ✓ items rendered alongside (or instead of) the entitlements list on the buy page. Render them too; they're how the operator describes the tier in plain language.policy.marketingBulletsPositionis"above"(default) or"below"— operator decides whether the marketing bullets appear before or after the entitlement chips on the card.policy.featuredDiscount(nullable) — when non-null, the operator has flagged this tier with an active launch-special discount. Surface it on the tier card: a struck-through original price, the discounted price, and a "LAUNCH SPECIAL" / "N% OFF" ribbon. Auto-apply the discount instartPurchaseby passing the featured code'scodestring in thecodeoption — otherwise the buyer pays the un-discounted price even though the card showed otherwise.product.priceCurrencycontrols how to format prices. Possible values:"SAT"(usepriceSatsas-is),"USD"/"EUR"(usepriceValuein cents and format as decimal dollars / euros). A product's policies inherit the product's currency; render accordingly.
Reference formatter (TypeScript):
function formatPrice(p: PublicPolicy, product: Product): string {
const cur = product.priceCurrency ?? 'SAT'
if (cur === 'SAT') return `${p.priceSats.toLocaleString()} sats`
const main = (p.priceValue ?? 0) / 100
return `${cur === 'USD' ? '$' : '€'}${main.toFixed(2)}`
}
Common mistakes
- Hardcoding policy slugs in the client. The whole point of
listPublicPoliciesis that the operator owns the tier shape. If you ship a build that only knows about Core and Pro, and the operator adds a "Patron" tier next month, the picker is silently stale. Render the picker off the live API response. - Splitting a product into multiple products. Don't. Different
tiers of the same product share the product slug and differ only on
the policy slug. Splitting breaks
validate()calls from clients that expect one canonicalproductSlug. The whole tier system is built on the assumption of one product, many policies. - Using discount codes as a tier discriminator.
codeis for promos and referral discounts. It can't change which tier the buyer ends up on. UsepolicySlug. - Forgetting
policySlugand assuming the right tier. WithpolicySlugomitted, the daemon picks the highlighted ("most popular") policy if any, else the cheapest. On a Core/Pro setup where Pro is highlighted, every buyer who hits your in-app upgrade flow without apolicySlugends up on Pro regardless of what they clicked. Always pass the slug the buyer chose. - Copying the price from your hardcoded UI rather than the API.
Operators legitimately edit tier pricing in admin without warning;
if you cache a price, you'll under- or over-charge buyers vs. what
they actually pay. Render
policy.priceSatsdirectly from the currentlistPublicPoliciesresponse. - Assuming all prices are in sats. A multi-currency product
(
priceCurrency === "USD"or"EUR") has its price in cents underpriceValueand a stale-or-zeropriceSats. Format onpriceCurrencypriceValue, notpriceSatsalone.
- Skipping the featured-discount surfacing. The buy page auto-
applies featured codes; your in-app picker doesn't unless you
forward
featuredDiscount.codeintostartPurchase. Without this, buyers see "LAUNCH SPECIAL" on your tier card but get charged the un-discounted price at checkout — a real bug.
Architecture diagram
Buyer in your app
│
▼
listPublicPolicies(slug) ← public, no auth
│
│ returns [{slug, name, priceSats, entitlements, ...}, ...]
▼
your in-app tier picker UI ← operator's branding
│
│ buyer clicks "Pro"
▼
startPurchase(slug, {policySlug: 'pro', buyerEmail, redirectUrl})
│
│ returns {checkoutUrl, invoiceId, ...}
▼
open checkoutUrl in browser ← BTCPay or Zaprite
│
│ buyer pays
▼
operator's licensing service ← webhook fires on settle
│
│ issues license with Pro entitlements + invoice.policy_id = 'pro'
▼
poll /v1/purchase/<id> OR webhook to your backend
│
│ returns license_key (signed string)
▼
write to /data/license.txt (or your chosen path)
│
▼
checkLicense() reloads, app sees Pro entitlements
This is the same architecture Keysat itself uses for its own self-licensing (cf section 17) and the same flow Recap implements in their Recap app's activation screen.
12. UX patterns for revocation & errors
When validate() returns ok: false, the reason field tells you why:
| reason | What to show the user |
|---|---|
revoked |
"This license has been revoked by the seller. Contact support." |
fingerprint_mismatch |
"This license is already active on another computer." |
not_found |
"License key not recognized. Did you copy it correctly?" |
bad_signature |
"This license appears tampered. Contact support." |
product_mismatch |
"This license is for a different product." |
expired |
"Your license expired on . Renew at ." |
Customize the copy for your tone, but show distinct messages — they mean very different things to the user.
13. Migrating from a non-Bitcoin licensing scheme (Gumroad, Stripe, etc.)
If you already sell licenses through a non-Bitcoin system, you don't have to do a flag-day migration. Two-phase plan:
Phase 1: dual-stack. Your app accepts BOTH old-format keys and
LIC1-… keys. They look different, so detection is trivial:
const isKeysat = raw.startsWith('LIC1-')
Honor old keys via your old verification path, new keys via the Keysat SDK. Both unlock the same features. Existing customers see no change.
Phase 2: cutover. When you're ready to retire the old system,
issue fresh LIC1- keys for existing customers via the operator's
admin "Issue license manually" action and email them with a one-line
"here's your new key" note. Mark the old format deprecated; don't
break it for some grace period.
For free → paid migrations, use free-license discount codes: the
operator creates a code with kind: free_license, max_uses: <your existing user count>, you put a "Redeem your existing-user code" button in your
app's first-launch screen, and existing users redeem once and never see
the prompt again.
14. Worked example: minimal Express server
A complete pattern for an Express + JS app. Copy-paste, replace
MYAPP, the slug, and the issuer PEM, and you have a working integration.
// server/license.js
const fs = require('node:fs')
const path = require('node:path')
const { Verifier, PublicKey } = require('@keysat/licensing-client')
const PRODUCT_SLUG = 'myapp'
const LICENSE_PATH = process.env.MYAPP_LICENSE_KEY_PATH || '/data/license.txt'
const ISSUER_PEM = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'issuer.pub'),
'utf8'
)
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM))
function readKey() {
if (process.env.MYAPP_LICENSE_KEY) return process.env.MYAPP_LICENSE_KEY.trim()
try { return fs.readFileSync(LICENSE_PATH, 'utf8').trim() } catch { return null }
}
function checkLicense() {
const raw = readKey()
if (!raw) return { state: 'unlicensed', entitlements: new Set() }
try {
const ok = verifier.verify(raw)
return {
state: 'licensed',
licenseId: ok.licenseId, // top-level shortcut on the VerifyOk result
entitlements: new Set(ok.payload.entitlements || []),
expiresAt: ok.payload.expiresAt
? new Date(ok.payload.expiresAt * 1000)
: null,
isTrial: ok.payload.isTrial, // SDK pre-parses the flags; don't bit-math
}
} catch (e) {
return { state: 'invalid', reason: e.message, entitlements: new Set() }
}
}
module.exports = { checkLicense, LICENSE_PATH }
// server/index.js
const express = require('express')
const { checkLicense, LICENSE_PATH } = require('./license')
const app = express()
const LIC = checkLicense()
console.log(`[license] state=${LIC.state} entitlements=[${[...LIC.entitlements].join(',')}]`)
if (LIC.state === 'invalid') {
console.warn(`[license] invalid: ${LIC.reason} — running unlicensed`)
}
// Free for everyone
app.get('/api/healthz', (_, res) => res.json({ ok: true }))
// Free tier: limited basic feature
app.post('/api/basic', (req, res) => {
res.json({ ok: true, result: 'basic feature' })
})
// Paid feature — gated on the `export` entitlement
app.post('/api/export', (req, res) => {
if (!LIC.entitlements.has('export')) {
return res.status(402).json({
error: 'feature_not_in_tier',
message: 'Export requires a paid license. See <upgrade_url>.',
license_path: LICENSE_PATH,
})
}
res.json({ ok: true, result: 'paid export' })
})
// Buyer-facing license status (so the frontend can show "licensed" badge
// and construct the buy URL without hard-coding it).
const KEYSAT_BASE_URL = 'https://licensing.example.com' // operator's instance
app.get('/api/license-status', (_, res) => {
res.json({
state: LIC.state,
reason: LIC.reason || null,
licenseId: LIC.licenseId || null,
entitlements: [...LIC.entitlements],
expiresAt: LIC.expiresAt || null,
isTrial: !!LIC.isTrial,
productSlug: PRODUCT_SLUG,
keysatBaseUrl: KEYSAT_BASE_URL,
})
})
app.listen(process.env.PORT || 8080)
That's a complete integration. ~75 lines. Replace the slug, the PEM file, and the entitlement names with what your operator chose, and ship it.
15. Common mistakes
- Embedding the wrong key. The PEM you embed is the public key
(from
GET /v1/issuer/public-key). The private key never leaves the operator's Keysat. If you accidentally ship a private key, every attacker can mint licenses. - Hard-failing on
validate()errors. If your app refuses to boot when validation throws, you've gated on the operator's server uptime. Always treat network errors as "status unknown" and fall back to the offline check. - Calling
validate()in a hot loop. Once at startup + once per hour is plenty. - Slug mismatch. A license issued for slug
foowon't validate against slugbar. Typos in the slug constant cause "license valid but my code rejects it" head-scratchers. Read the slug from a single constant. - Not asserting the product after offline verify.
verifier.verifychecks the signature, not the product. If the operator sells multiple products from the same Keysat, every product's licenses share the signing key — a license for Product A will signature-verify inside Product B's app. The payload carries a product UUID, not a slug; the right check is either (a) callclient.validate(key, slug, …)so the daemon resolves slug → UUID server-side, or (b) embed the operator's product UUID and assertpayload.productUuid === MY_PRODUCT_UUIDoffline. See §9a for both patterns. - Doing flag bit-arithmetic for
isTrial.(payload.flags & 1)is theFINGERPRINT_BOUNDbit, NOTTRIAL. The TS / Python / Go SDKs pre-parse the flags intoisTrial/is_trial/IsTrial()— use those. The Rust SDK requires manual math; if you need it, use the exportedFLAG_TRIALconstant (mask0x02). - Logging the full license key. It's a bearer credential — log
the
license_idinstead. - Refusing to start without a license. Boot in unlicensed mode and let the user keep using whatever's free-tier. Much better UX than exit-on-startup.
- Forgetting to
COPYthe new license module into the container. If your Dockerfile lists individual server files explicitly, addingserver/license.jsrequires its ownCOPYline. Build succeeds, container starts, then crashes at startup withCannot find module './license.js'. See §7e for the full Docker checklist. - Letting the SDK ship without a built
dist/. Git installs of the Keysat client only work if the package has apreparescript that builds on install (or commits itsdist/directory). Without that, the install succeeds but the package is empty. If you publish to npm, this isn't a problem —prepublishOnlybuildsdist/for you. If you only host on GitHub, ensureprepareis wired. - Using
github:user/reposhorthand instead ofgit+https://.... The shorthand often resolves to SSH on machines with a GitHub key, which then breaks every hermetic build downstream. Always use the explicitgit+https://github.com/...form, and double-check theresolved:field in yourpackage-lock.jsonafter switching — npm caches the previous resolution and may keep an SSH URL in the lock even after you change the spec. - Skipping the frontend half of hard-gate Flavor 2. A server-only integration boots happily but every request 402s, which the unlicensed user experiences as a broken app rather than a clear "activate to continue" screen. See §7f for the framework-agnostic pattern.
15a. Verify your integration with curl before writing app code
Before wiring anything into the app, confirm the operator's Keysat is reachable and configured correctly. These four commands take ~30 seconds and catch most "we agreed on the wrong slug" failures.
# Replace with your operator's values.
export KEYSAT_BASE_URL='https://licensing.example.com'
export PRODUCT_SLUG='your-product-slug'
# 1. Daemon is reachable.
curl -fsSL "$KEYSAT_BASE_URL/healthz" && echo " healthz OK"
# 2. Issuer public key endpoint responds (so embed-time fetch works).
curl -fsSL "$KEYSAT_BASE_URL/v1/issuer/public-key" | head -1
# 3. The product slug exists and has at least one policy. If the JSON
# response is { "error": "not_found" }, you have a slug typo or the
# operator hasn't created the product yet.
curl -fsSL "$KEYSAT_BASE_URL/v1/products/$PRODUCT_SLUG/policies" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("policies:", [p["slug"] for p in d.get("policies", [])])'
# 4. Validate a real license key (ask the operator for one — a free-
# license discount-code redemption is the cheapest path).
LICENSE_KEY='LIC1-...'
curl -fsSL -X POST "$KEYSAT_BASE_URL/v1/validate" \
-H 'content-type: application/json' \
-d '{"key":"'"$LICENSE_KEY"'","product":"'"$PRODUCT_SLUG"'"}' \
| python3 -m json.tool
If all four commands succeed, the daemon is wired correctly and you have a valid license key to test against. Now write the integration.
If step 3 returns an empty policies array, the operator hasn't
created any policies for this product yet — surface that to them
before continuing (no policies means no buyer-purchasable tiers).
If step 4 returns { "ok": false, "reason": "..." }, the license
isn't valid for this product. The most common cause is a slug typo
in the operator's product setup vs. what they told you.
16. Testing the integration
- Get a real license to test against. Easiest: ask the operator to
issue you one manually from their admin UI's "Manually issue a
license" form (Licenses tab). Or, if they've created a
free_licensediscount code, redeem it:curl -X POST https://licensing.example.com/v1/redeem -H 'content-type: application/json' -d '{"product":"<slug>","code":"<code>"}'— the response includes alicense_key. - Save the key to
/data/license.txt(or wherever you read from). - Restart your app.
- Look for a startup log line:
[license] state=licensed entitlements=[…]. - Hit a paid endpoint — should succeed.
- Hit a paid endpoint after deleting the license file — should return 402.
- Tamper with one character of the key — should log
state=invalid. - (Online) Have the operator revoke the license; on next online check,
reason should be
revoked.
17. Reference: Keysat dogfoods this same pattern
The Keysat daemon itself uses this exact integration to license itself.
license_self.rs
in the Keysat repo:
- Embeds the master public key as
TRUST_ROOT_PUBKEY_PEM. - Reads the license from
/data/keysat-license.txtat boot. - Verifies via the same
parse_key + verify_payloadmachinery the SDK uses. - Exposes
state.self_tier.entitlementsto the rest of the daemon. - Other handlers gate features on entitlements (e.g.,
unlimited_products,recurring_billing) — seetier.rsfor the canonical gate-helper pattern.
If you want a working precedent to copy, that's the cleanest one in the codebase. The pattern is identical to what your app should do.