19a969f797
Standard docs-site convention: top-left brand goes to the marketing home, the 'Docs' badge next to it signals you're in the docs section. The separate 'Marketing' nav item is no longer needed once the brand itself handles that link.
284 lines
16 KiB
HTML
284 lines
16 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Keysat Docs — Integrate the SDK</title>
|
|
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
|
<link rel="stylesheet" href="docs.css">
|
|
</head>
|
|
<body>
|
|
|
|
<div class="topnav">
|
|
<a href="https://keysat.xyz" class="brand" title="Back to keysat.xyz"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a>
|
|
<span class="docs-tag">Docs</span>
|
|
<nav>
|
|
<a href="install.html">Install</a>
|
|
<a href="integrate.html" class="active">Integrate</a>
|
|
<a href="wire-format.html">Wire format</a>
|
|
<a href="operate.html">Operate</a>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<aside class="side">
|
|
<div class="group">
|
|
<div class="glabel">Get started</div>
|
|
<a href="index.html">Introduction</a>
|
|
<a href="install.html">Install & setup</a>
|
|
<a href="integrate.html" class="active">Integrate the SDK</a>
|
|
</div>
|
|
<div class="group">
|
|
<div class="glabel">Concepts</div>
|
|
<a href="index.html#architecture">Architecture</a>
|
|
<a href="index.html#products-policies">Products & policies</a>
|
|
<a href="index.html#discounts">Discount codes</a>
|
|
<a href="index.html#revocation">Revocation strategy</a>
|
|
</div>
|
|
<div class="group">
|
|
<div class="glabel">Reference</div>
|
|
<a href="wire-format.html">Wire format</a>
|
|
<a href="integrate.html#api">Admin API</a>
|
|
<a href="integrate.html#sdks">SDKs</a>
|
|
</div>
|
|
<div class="group">
|
|
<div class="glabel">Operate</div>
|
|
<a href="operate.html#backups">Backups</a>
|
|
<a href="operate.html#migrate">Migrate hardware</a>
|
|
<a href="operate.html#troubleshooting">Troubleshooting</a>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="prose">
|
|
<div class="crumb">Get started · Integrate the SDK</div>
|
|
<h1>Integrate the SDK.</h1>
|
|
<p class="lead">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.</p>
|
|
|
|
<h2 id="prereq">Prerequisites</h2>
|
|
<p>Before you start, you should have:</p>
|
|
<ul>
|
|
<li>A Keysat installation running on your Start9 — see <a href="install.html">Install & setup</a>.</li>
|
|
<li>BTCPay Server connected to Keysat — ditto.</li>
|
|
<li>At least one product defined in the admin UI.</li>
|
|
</ul>
|
|
|
|
<h2 id="sdks">Pick an SDK</h2>
|
|
<p>Three official SDKs ship today. They are wire-compatible — a license issued by your Keysat verifies identically in any of them.</p>
|
|
|
|
<div class="lang-tabs" role="tablist">
|
|
<button class="active" data-lang="ts">TypeScript</button>
|
|
<button data-lang="rs">Rust</button>
|
|
<button data-lang="py">Python</button>
|
|
</div>
|
|
|
|
<pre class="code lang-pane" data-lang="ts"><span class="c"># npm</span>
|
|
npm install @keysat/licensing-client
|
|
|
|
<span class="c"># pnpm</span>
|
|
pnpm add @keysat/licensing-client</pre>
|
|
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c"># Cargo.toml</span>
|
|
[dependencies]
|
|
licensing-client = <span class="s">"0.1"</span></pre>
|
|
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># pip</span>
|
|
pip install keysat-licensing-client
|
|
|
|
<span class="c"># or with poetry</span>
|
|
poetry add keysat-licensing-client</pre>
|
|
|
|
<p>If your language isn’t covered, see <a href="wire-format.html">Wire format</a>. The format is small and porting takes about an afternoon.</p>
|
|
|
|
<h2 id="embed">Step 1 — Embed your public key</h2>
|
|
<p>In the admin UI, open <strong>Overview</strong> and copy the issuer public key from the "Embed your public key" card. (Or fetch it from <code>GET /v1/issuer/public-key</code>.) Paste it into your application’s source code as a compile-time constant.</p>
|
|
|
|
<pre class="code lang-pane" data-lang="ts"><span class="k">const</span> <span class="f">ISSUER_PEM</span> = <span class="s">`-----BEGIN PUBLIC KEY-----
|
|
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
|
|
-----END PUBLIC KEY-----`</span>;</pre>
|
|
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">const</span> <span class="f">ISSUER_PEM</span>: &<span class="k">str</span> = <span class="s">"-----BEGIN PUBLIC KEY-----\n\
|
|
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL\n\
|
|
-----END PUBLIC KEY-----"</span>;</pre>
|
|
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="f">ISSUER_PEM</span> = <span class="s">b"""-----BEGIN PUBLIC KEY-----
|
|
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
|
|
-----END PUBLIC KEY-----"""</span></pre>
|
|
|
|
<div class="callout">
|
|
<i data-lucide="info"></i>
|
|
<p><strong>Embed it. Don’t fetch it.</strong> 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.</p>
|
|
</div>
|
|
|
|
<h2 id="verify">Step 2 — Verify a license at startup</h2>
|
|
<p>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.</p>
|
|
|
|
<pre class="code lang-pane" data-lang="ts"><span class="k">import</span> { <span class="f">Verifier</span>, <span class="f">PublicKey</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>;
|
|
|
|
<span class="k">const</span> verifier = <span class="k">new</span> <span class="f">Verifier</span>(
|
|
<span class="f">PublicKey</span>.<span class="f">fromPem</span>(ISSUER_PEM)
|
|
);
|
|
|
|
<span class="k">const</span> result = verifier.<span class="f">verify</span>(licenseKeyFromUser);
|
|
|
|
<span class="c">// Now decide what to do with the result, based on YOUR business model.
|
|
// One-time purchase to use the app at all? Refuse to start unless valid.
|
|
// Free + paid features? Check entitlements per feature.
|
|
// Supporter badge only? Just render differently when valid.</span>
|
|
<span class="k">if</span> (result.valid) {
|
|
app.licensed = <span class="k">true</span>;
|
|
app.entitlements = result.entitlements;
|
|
}</pre>
|
|
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">use</span> licensing_client::{<span class="f">Verifier</span>, <span class="f">PublicKeyPem</span>};
|
|
|
|
<span class="k">let</span> pk = <span class="f">PublicKeyPem</span>::from_str(ISSUER_PEM)<span class="p">?</span>;
|
|
<span class="k">let</span> verifier = <span class="f">Verifier</span>::new(pk);
|
|
<span class="k">let</span> result = verifier.verify(&license_key)<span class="p">?</span>;
|
|
|
|
<span class="c">// What you do next is up to your business model.</span>
|
|
<span class="k">if</span> result.valid {
|
|
app.licensed = <span class="k">true</span>;
|
|
app.entitlements = result.entitlements;
|
|
}</pre>
|
|
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> Verifier, PublicKey
|
|
|
|
verifier = <span class="f">Verifier</span>(<span class="f">PublicKey</span>.<span class="f">from_pem</span>(ISSUER_PEM))
|
|
result = verifier.<span class="f">verify</span>(license_key_from_user)
|
|
|
|
<span class="c"># What you do with the result is your choice.</span>
|
|
<span class="k">if</span> result.valid:
|
|
app.licensed = <span class="k">True</span>
|
|
app.entitlements = result.entitlements</pre>
|
|
|
|
<p>The verifier returns a result object with the following fields:</p>
|
|
|
|
<table class="t">
|
|
<thead><tr><th>Field</th><th>Type</th><th>Meaning</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>valid</code></td><td><code>bool</code></td><td>Signature checked, expiry not exceeded.</td></tr>
|
|
<tr><td><code>product_id</code></td><td><code>string</code></td><td>The product slug this license was issued for.</td></tr>
|
|
<tr><td><code>policy_slug</code></td><td><code>string</code></td><td>Which policy was active at issue time.</td></tr>
|
|
<tr><td><code>license_id</code></td><td><code>string</code></td><td>UUID of the license; useful for support tickets.</td></tr>
|
|
<tr><td><code>issued_at</code></td><td><code>Date</code></td><td>UTC timestamp.</td></tr>
|
|
<tr><td><code>expires_at</code></td><td><code>Date | null</code></td><td><code>null</code> for perpetual.</td></tr>
|
|
<tr><td><code>is_trial</code></td><td><code>bool</code></td><td>Set by the policy at issue time.</td></tr>
|
|
<tr><td><code>seats</code></td><td><code>int</code></td><td>Max machines (0 = unlimited).</td></tr>
|
|
<tr><td><code>entitlements</code></td><td><code>Set<string></code></td><td>Feature flags baked into the signed payload.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2 id="errors">Step 3 — Handle errors gracefully</h2>
|
|
<p>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:</p>
|
|
|
|
<pre class="code lang-pane" data-lang="ts"><span class="k">try</span> {
|
|
<span class="k">const</span> result = verifier.<span class="f">verify</span>(licenseKey);
|
|
<span class="k">if</span> (result.valid) <span class="f">grantAccess</span>(result);
|
|
<span class="k">else</span> <span class="f">showRenewalPrompt</span>(result.expires_at);
|
|
} <span class="k">catch</span> (e) {
|
|
<span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">SignatureError</span>) <span class="f">showTamperWarning</span>();
|
|
<span class="k">else</span> <span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">FormatError</span>) <span class="f">showInputError</span>();
|
|
<span class="k">else</span> <span class="f">showGenericError</span>(e);
|
|
}</pre>
|
|
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">match</span> verifier.verify(&license_key) {
|
|
<span class="k">Ok</span>(r) <span class="k">if</span> r.valid => grant_access(&r),
|
|
<span class="k">Ok</span>(r) => show_renewal_prompt(r.expires_at),
|
|
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::SignatureError) => show_tamper_warning(),
|
|
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::FormatError(_)) => show_input_error(),
|
|
<span class="k">Err</span>(e) => show_generic_error(e),
|
|
}</pre>
|
|
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> SignatureError, FormatError
|
|
|
|
<span class="k">try</span>:
|
|
result = verifier.<span class="f">verify</span>(license_key)
|
|
<span class="k">if</span> result.valid: grant_access(result)
|
|
<span class="k">else</span>: show_renewal_prompt(result.expires_at)
|
|
<span class="k">except</span> SignatureError:
|
|
show_tamper_warning()
|
|
<span class="k">except</span> FormatError:
|
|
show_input_error()</pre>
|
|
|
|
<h2 id="renewals">Renewals & revocation</h2>
|
|
<p>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.</p>
|
|
|
|
<p>If you need revocation, ship a thin <em>online</em> check that runs on a cadence (e.g. once a week) against your Keysat’s revocation feed:</p>
|
|
|
|
<pre class="code lang-pane" data-lang="ts"><span class="c">// Optional. Run on a cadence, ignore network errors.</span>
|
|
<span class="k">async function</span> <span class="f">checkRevocation</span>(licenseId: string) {
|
|
<span class="k">const</span> r = <span class="k">await</span> fetch(<span class="s">`https://your-keysat.example/v1/licenses/${licenseId}/status`</span>);
|
|
<span class="k">if</span> (r.ok) {
|
|
<span class="k">const</span> j = <span class="k">await</span> r.json();
|
|
<span class="k">if</span> (j.status === <span class="s">'revoked'</span>) <span class="f">disableApp</span>();
|
|
}
|
|
}</pre>
|
|
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c">// Optional. Run on a cadence, ignore network errors.</span>
|
|
<span class="k">async fn</span> check_revocation(license_id: &<span class="k">str</span>) {
|
|
<span class="k">if let</span> <span class="k">Ok</span>(r) = reqwest::get(format!(
|
|
<span class="s">"https://your-keysat.example/v1/licenses/{}/status"</span>,
|
|
license_id
|
|
)).<span class="k">await</span> {
|
|
<span class="k">if let</span> <span class="k">Ok</span>(j) = r.json::<Status>().<span class="k">await</span> {
|
|
<span class="k">if</span> j.status == <span class="s">"revoked"</span> { disable_app(); }
|
|
}
|
|
}
|
|
}</pre>
|
|
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># Optional. Run on a cadence, ignore network errors.</span>
|
|
<span class="k">def</span> <span class="f">check_revocation</span>(license_id):
|
|
<span class="k">try</span>:
|
|
r = requests.get(<span class="s">f"https://your-keysat.example/v1/licenses/{license_id}/status"</span>, timeout=<span class="n">5</span>)
|
|
<span class="k">if</span> r.json()[<span class="s">"status"</span>] == <span class="s">"revoked"</span>:
|
|
disable_app()
|
|
<span class="k">except</span> Exception:
|
|
<span class="k">pass</span></pre>
|
|
|
|
<div class="callout">
|
|
<i data-lucide="key-round"></i>
|
|
<p><strong>You decide the policy.</strong> Many indie developers ship no revocation at all. Once a key is sold, it stays valid — refunds happen offline via BTCPay. That’s perfectly reasonable.</p>
|
|
</div>
|
|
|
|
<h2 id="api">Admin API</h2>
|
|
<p>The admin UI is a thin shell over a small JSON API. Bearer-auth all requests with your admin API key.</p>
|
|
|
|
<table class="t">
|
|
<thead><tr><th>Method</th><th>Path</th><th>Use</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>GET</code></td><td><code>/v1/products</code></td><td>List products (public).</td></tr>
|
|
<tr><td><code>POST</code></td><td><code>/v1/admin/products</code></td><td>Create a product.</td></tr>
|
|
<tr><td><code>POST</code></td><td><code>/v1/admin/policies</code></td><td>Create a policy.</td></tr>
|
|
<tr><td><code>POST</code></td><td><code>/v1/admin/discount-codes</code></td><td>Create a discount or comp code.</td></tr>
|
|
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses/search</code></td><td>Find licenses by email, npub, or invoice.</td></tr>
|
|
<tr><td><code>POST</code></td><td><code>/v1/admin/licenses/<id>/revoke</code></td><td>Revoke a license.</td></tr>
|
|
<tr><td><code>POST</code></td><td><code>/v1/admin/webhook-endpoints</code></td><td>Register an outbound webhook.</td></tr>
|
|
<tr><td><code>GET</code></td><td><code>/v1/admin/audit</code></td><td>Read audit log.</td></tr>
|
|
<tr><td><code>POST</code></td><td><code>/v1/redeem</code></td><td>Redeem a free-license code (public).</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p>Full schemas for each endpoint live in <a href="wire-format.html">Wire format & API reference</a>.</p>
|
|
</main>
|
|
|
|
<aside class="toc">
|
|
<div class="label">On this page</div>
|
|
<a href="#prereq">Prerequisites</a>
|
|
<a href="#sdks">Pick an SDK</a>
|
|
<a href="#embed">1. Embed your public key</a>
|
|
<a href="#verify">2. Verify at startup</a>
|
|
<a href="#errors">3. Handle errors</a>
|
|
<a href="#renewals">Renewals & revocation</a>
|
|
<a href="#api">Admin API</a>
|
|
</aside>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<script>
|
|
lucide.createIcons();
|
|
|
|
// Sync language tabs across all .lang-pane code blocks on the page
|
|
function setLang(lang) {
|
|
document.querySelectorAll('.lang-tabs button').forEach(b =>
|
|
b.classList.toggle('active', b.dataset.lang === lang));
|
|
document.querySelectorAll('.lang-pane').forEach(p => {
|
|
p.style.display = (p.dataset.lang === lang) ? 'block' : 'none';
|
|
});
|
|
}
|
|
document.querySelectorAll('.lang-tabs button').forEach(b => {
|
|
b.addEventListener('click', () => setLang(b.dataset.lang));
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|