Correct SDK-integration docs and add license-gating walkthrough

Fixes surfaced by the onboarding test harness, each verified against the
published SDKs and the daemon:

- integrate.html: real v0.3 verify() shape (throws/Err, returns
  VerifyOk{payload,...}, no `valid` bool; LicensingError `.code` in TS,
  `.kind` in Python; Rust Error::BadSignature/BadFormat). Offline-expiry
  and server-side key-transport notes; corrected the admin-API table
  (licenses list needs product_id; added the /search row).
- agent.html: merchant-onboard role row; product/policy-create workflows;
  buyer_note -> note; find-by-email -> /search; the worked-example
  walkthrough; code blocks restyled to the pre.code design contract.
- wire-format.html: corrected the GET /v1/issuer/public-key response shape.
This commit is contained in:
Keysat
2026-06-16 22:47:59 -05:00
parent 3f1fbe0f3b
commit 47facc8909
3 changed files with 186 additions and 77 deletions
+123 -23
View File
@@ -54,7 +54,7 @@
<p>This guide covers the <em>operator side</em> of Keysat: running, configuring, and performing day-to-day operations. For the <em>buyer side</em> (validating licenses inside your app), see <a href="integrate.html">Integrate the SDK</a>.</p>
<h2 id="quick-start">Quick start</h2>
<pre><code># 1. Discover the API surface
<pre class="code"># 1. Discover the API surface
curl https://your-keysat-host/v1/openapi.json
# 2. Generate a scoped API key (admin UI: Settings → API keys, or via curl)
@@ -66,11 +66,11 @@ curl -X POST https://your-keysat-host/v1/admin/api-keys \
# 3. Use the scoped key
curl https://your-keysat-host/v1/admin/licenses?status=active \
-H "Authorization: Bearer ks_..."</code></pre>
-H "Authorization: Bearer ks_..."</pre>
<h2 id="auth">Authentication</h2>
<p>All admin endpoints use HTTP Bearer auth:</p>
<pre><code>Authorization: Bearer &lt;token&gt;</code></pre>
<pre class="code">Authorization: Bearer &lt;token&gt;</pre>
<p>Two kinds of tokens are accepted.</p>
<p><strong>Master admin API key</strong>: the env-configured <code>KEYSAT_ADMIN_API_KEY</code> (visible in StartOS Actions &rarr; Show credentials on first install). Full access to every endpoint. This is the operator's credential. Don't hand it to agents.</p>
@@ -84,6 +84,7 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
<tr><td><code>read-only</code></td><td>List / get every resource. Mutate nothing.</td></tr>
<tr><td><code>license-issuer</code></td><td>All <code>read-only</code> scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes.</td></tr>
<tr><td><code>support</code></td><td>All <code>license-issuer</code> scopes + cancel subscriptions + force-deactivate machines.</td></tr>
<tr><td><code>merchant-onboard</code></td><td>All <code>read-only</code> scopes + create / update products, policies, and licenses. The self-serve catalog role: stand up a product, define its tiers, and issue licenses without ever touching the master key. Cannot change settings, connect payment providers, or manage API keys.</td></tr>
<tr><td><code>full-admin</code></td><td>Every scope. Equivalent to the master key for most endpoints.</td></tr>
</tbody>
</table>
@@ -108,12 +109,12 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
<h2 id="envelope">Response envelope conventions</h2>
<p>Every error response uses the same JSON envelope:</p>
<pre><code>{
<pre class="code">{
"ok": false,
"error": "tier_cap",
"message": "Your Creator tier allows up to 5 products. You're at 5...",
"upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro"
}</code></pre>
}</pre>
<p><code>error</code> is a stable machine-readable code; <code>message</code> is human-readable. The <code>upgrade_url</code> field appears on 402 (tier cap) responses so a UI can render an upgrade CTA without parsing message strings.</p>
<h3>Error codes</h3>
@@ -151,62 +152,160 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
<h2 id="workflows">Common workflows</h2>
<h3>Create a product</h3>
<pre class="code">curl -X POST $KS/v1/admin/products \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{
"slug": "acme-app",
"name": "Acme App",
"price_currency": "SAT",
"price_value": 50000,
"entitlements_catalog": [
{ "slug": "pro_export", "name": "Pro export", "description": "CSV export" }
]
}'</pre>
<p><code>price_value</code> is the write field: the price in the smallest unit of
<code>price_currency</code> (sats for <code>SAT</code>, cents for <code>USD</code> /
<code>EUR</code>). The response also echoes a legacy <code>price_sats</code> field; it's
still accepted on create for backward compatibility, but new callers should send
<code>price_value</code> + <code>price_currency</code> instead. <code>entitlements_catalog</code> is the
closed list of feature slugs your policies may grant; omit it to allow free-text
entitlements instead.</p>
<p><em>Scope required: <code>products:write</code>.</em></p>
<h3>Add a tier (policy)</h3>
<pre class="code">curl -X POST $KS/v1/admin/policies \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{
"product_slug": "acme-app",
"name": "Lifetime Pro",
"slug": "pro",
"duration_seconds": 0,
"max_machines": 0,
"entitlements": ["pro_export"]
}'</pre>
<p><code>duration_seconds: 0</code> = perpetual; <code>max_machines: 0</code> = unlimited
seats. Each entry in <code>entitlements</code> must be a slug declared in the product's
<code>entitlements_catalog</code> (when one is set). Returns the created policy, including
its <code>id</code>.</p>
<p><em>Scope required: <code>policies:write</code>.</em></p>
<h3>Issue a comp license</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses \
<pre class="code">curl -X POST $KS/v1/admin/licenses \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{
"product_slug": "recap",
"policy_slug": "pro",
"buyer_email": "alice@example.com",
"buyer_note": "Conference speaker comp"
}'</code></pre>
"note": "Conference speaker comp"
}'</pre>
<p>Returns the issued license object including <code>license_key</code>. The buyer pastes the key into their app; subsequent validate calls return <code>ok: true</code> with the policy's entitlements.</p>
<p><em>Scope required: <code>licenses:write</code> (any role except <code>read-only</code>).</em></p>
<h3>Revoke a license</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
<pre class="code">curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{"reason":"customer request"}'</code></pre>
-d '{"reason":"customer request"}'</pre>
<p>Idempotent. The next online validate from the buyer's app returns <code>reason: revoked</code>.</p>
<p><em>Scope required: <code>licenses:write</code>.</em></p>
<h3>Find a license by email</h3>
<pre><code>curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \
-H "Authorization: Bearer ks_..."</code></pre>
<pre class="code">curl "$KS/v1/admin/licenses/search?buyer_email=alice@example.com" \
-H "Authorization: Bearer ks_..."</pre>
<p>Returns matching licenses (without the <code>license_key</code> field, which is only returned on issue / recover). Use the <code>id</code> for follow-up operations.</p>
<p><em>Scope required: <code>licenses:read</code>.</em></p>
<h3>Cancel a buyer's subscription</h3>
<pre><code># Look up the subscription id first (filter by license_id if you have it)
<pre class="code"># Look up the subscription id first (filter by license_id if you have it)
curl "$KS/v1/admin/subscriptions?status=active" \
-H "Authorization: Bearer ks_..."
# Then cancel
curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer requested"}'</code></pre>
-d '{"reason":"buyer requested"}'</pre>
<p>License stays valid through the current cycle's <code>expires_at</code>. Renewal worker stops issuing new invoices.</p>
<p><em>Scope required: <code>subscriptions:write</code>.</em></p>
<h3>Free a machine seat</h3>
<pre><code>curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
<pre class="code">curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer moved devices"}'</code></pre>
-d '{"reason":"buyer moved devices"}'</pre>
<p>The seat opens up. The buyer's next validate from any machine takes the freed seat.</p>
<p><em>Scope required: <code>machines:write</code>.</em></p>
<h3>Programmatic tier change (comp upgrade)</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
<pre class="code">curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
-H "Authorization: Bearer ks_..." \
-d '{
"target_policy_slug": "pro",
"reason": "support resolution"
}'</code></pre>
}'</pre>
<p>Always applies as comp (no invoice) from the admin path. Buyer-initiated paid upgrades go through <code>/v1/upgrade</code> (different endpoint, signed-license auth).</p>
<p><em>Scope required: <code>licenses:write</code>.</em></p>
<h2 id="worked-example">Worked example: gate an app behind a license</h2>
<p>End to end, with nothing but a <code>merchant-onboard</code> scoped key and the SDK: stand up a product, issue a license, and gate a feature in a Next.js app. No payment provider is needed for this path (you're issuing comp/dev licenses by hand; wiring BTCPay so buyers can self-checkout is covered in <a href="install.html">Install &amp; setup</a>). This is the minimal true path, nothing more.</p>
<p><strong>1. On the server.</strong> Create the catalog and issue a license. Replace <code>$KS</code> with your Keysat URL and <code>ks_...</code> with your scoped key.</p>
<pre class="code"># Fetch the issuer public key; you'll embed it in your app
curl $KS/v1/issuer/public-key
# -> { "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }
# Define the product, declaring the entitlements its tiers may grant
curl -X POST $KS/v1/admin/products -H "Authorization: Bearer ks_..." -H "Content-Type: application/json" -d '{
"slug": "acme-reports", "name": "Acme Reports",
"price_currency": "SAT", "price_value": 50000,
"entitlements_catalog": [{ "slug": "pro_export", "name": "Pro export", "description": "CSV export" }]
}'
# Add a perpetual tier that grants pro_export
curl -X POST $KS/v1/admin/policies -H "Authorization: Bearer ks_..." -H "Content-Type: application/json" -d '{
"product_slug": "acme-reports", "name": "Lifetime Pro", "slug": "pro",
"duration_seconds": 0, "max_machines": 0, "entitlements": ["pro_export"]
}'
# Issue a comp license; the response carries the LIC1-... license_key
curl -X POST $KS/v1/admin/licenses -H "Authorization: Bearer ks_..." -H "Content-Type: application/json" -d '{
"product_slug": "acme-reports", "policy_slug": "pro",
"buyer_email": "dev@example.com", "note": "Dev comp"
}'</pre>
<p><strong>2. In your app.</strong> Install the SDK and verify the license offline. <code>verify()</code> returns on success and throws a <code>LicensingError</code> on any bad key; there is no <code>valid</code> boolean.</p>
<pre class="code">npm install @keysat/licensing-client</pre>
<pre class="code">// app/api/export/route.ts
import { Verifier, PublicKey, LicensingError } from "@keysat/licensing-client";
const ISSUER_PEM = `-----BEGIN PUBLIC KEY-----
&lt;paste your public_key_pem here&gt;
-----END PUBLIC KEY-----`;
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM));
export async function GET(request: Request) {
const key = request.headers.get("X-License-Key");
if (!key) return Response.json({ error: "no_license" }, { status: 401 });
try {
verifier.verify(key); // throws if missing / forged / garbled
} catch (e) {
const code = e instanceof LicensingError ? e.code : "unknown";
return Response.json({ error: code }, { status: 403 });
}
// Verified. Serve the protected resource.
return new Response(toCsv(ROWS), { status: 200, headers: { "Content-Type": "text/csv" } });
}</pre>
<p><strong>3. Confirm the gate.</strong> A valid license is accepted; absent and tampered keys are refused. <code>$APP</code> is your running app.</p>
<pre class="code">curl -s -o /dev/null -w "%{http_code}\n" $APP/api/export # 401 (no header)
curl -s -o /dev/null -w "%{http_code}\n" $APP/api/export -H "X-License-Key: $LICENSE_KEY" # 200 + CSV
curl -s -o /dev/null -w "%{http_code}\n" $APP/api/export -H "X-License-Key: LIC1-TAMPERED" # 403</pre>
<p>That's the whole path: catalog, license, and an offline-verified gate. No network call to Keysat happens during request handling, so your app keeps working even if your Keysat instance is briefly unreachable.</p>
<h2 id="webhooks">Webhooks: react to events instead of polling</h2>
<p>Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:</p>
<table>
@@ -225,11 +324,11 @@ curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
</table>
<p>Verify signatures:</p>
<pre><code>import hmac, hashlib
<pre class="code">import hmac, hashlib
def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)</code></pre>
return hmac.compare_digest(expected, signature_header)</pre>
<p>The header is <code>X-Keysat-Signature</code>. Failed deliveries retry with exponential backoff up to 10 attempts; permanently-failed deliveries land in the DLQ visible at admin UI → Webhooks → Failed.</p>
<h2 id="robust">Designing a robust agent</h2>
@@ -251,7 +350,7 @@ def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
<p><code>internal_error</code> (500) is a bug or a transient DB lock. Retry with exponential backoff (1s, 2s, 4s, 8s, give up). Don't retry on 4xx. Those are deterministic client errors.</p>
<h2 id="recipe">Concrete recipe: "Comp a license to anyone who emails support@"</h2>
<pre><code>import os, requests, imaplib, email
<pre class="code">import os, requests, imaplib, email
KS = os.environ["KEYSAT_URL"]
TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key
@@ -264,14 +363,14 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
"product_slug": product_slug,
"policy_slug": "default",
"buyer_email": buyer_email,
"buyer_note": reason,
"note": reason,
},
timeout=10,
)
r.raise_for_status()
return r.json()["license_key"]
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key</code></pre>
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key</pre>
<p>That's the entire pattern. The agent doesn't need full admin, just the license-issuer role. If it ever gets compromised, you revoke the scoped key in the admin UI and generate a new one in 30 seconds.</p>
<h2 id="not-exposed">What's NOT exposed to agents</h2>
@@ -297,6 +396,7 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
<a href="#discovery">Discovering the API</a>
<a href="#envelope">Response envelope</a>
<a href="#workflows">Common workflows</a>
<a href="#worked-example">Worked example</a>
<a href="#webhooks">Webhooks</a>
<a href="#robust">Designing a robust agent</a>
<a href="#recipe">Recipe: comp-license bot</a>