From 47facc8909d655744dffb29ea04890311f456f8f Mon Sep 17 00:00:00 2001 From: Keysat Date: Tue, 16 Jun 2026 22:47:59 -0500 Subject: [PATCH] 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. --- agent.html | 146 +++++++++++++++++++++++++++++++++++++++-------- integrate.html | 110 ++++++++++++++++++----------------- wire-format.html | 7 ++- 3 files changed, 186 insertions(+), 77 deletions(-) diff --git a/agent.html b/agent.html index 7e1873f..c67a160 100644 --- a/agent.html +++ b/agent.html @@ -54,7 +54,7 @@

This guide covers the operator side of Keysat: running, configuring, and performing day-to-day operations. For the buyer side (validating licenses inside your app), see Integrate the SDK.

Quick start

-
# 1. Discover the API surface
+    
# 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_..."
+ -H "Authorization: Bearer ks_..."

Authentication

All admin endpoints use HTTP Bearer auth:

-
Authorization: Bearer <token>
+
Authorization: Bearer <token>

Two kinds of tokens are accepted.

Master admin API key: the env-configured KEYSAT_ADMIN_API_KEY (visible in StartOS Actions → Show credentials on first install). Full access to every endpoint. This is the operator's credential. Don't hand it to agents.

@@ -84,6 +84,7 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \ read-onlyList / get every resource. Mutate nothing. license-issuerAll read-only scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. supportAll license-issuer scopes + cancel subscriptions + force-deactivate machines. + merchant-onboardAll read-only 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. full-adminEvery scope. Equivalent to the master key for most endpoints. @@ -108,12 +109,12 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \

Response envelope conventions

Every error response uses the same JSON envelope:

-
{
+    
{
   "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"
-}
+}

error is a stable machine-readable code; message is human-readable. The upgrade_url field appears on 402 (tier cap) responses so a UI can render an upgrade CTA without parsing message strings.

Error codes

@@ -151,62 +152,160 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \

Common workflows

+

Create a product

+
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" }
+    ]
+  }'
+

price_value is the write field: the price in the smallest unit of + price_currency (sats for SAT, cents for USD / + EUR). The response also echoes a legacy price_sats field; it's + still accepted on create for backward compatibility, but new callers should send + price_value + price_currency instead. entitlements_catalog is the + closed list of feature slugs your policies may grant; omit it to allow free-text + entitlements instead.

+

Scope required: products:write.

+ +

Add a tier (policy)

+
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"]
+  }'
+

duration_seconds: 0 = perpetual; max_machines: 0 = unlimited + seats. Each entry in entitlements must be a slug declared in the product's + entitlements_catalog (when one is set). Returns the created policy, including + its id.

+

Scope required: policies:write.

+

Issue a comp license

-
curl -X POST $KS/v1/admin/licenses \
+    
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"
-  }'
+ "note": "Conference speaker comp" + }'

Returns the issued license object including license_key. The buyer pastes the key into their app; subsequent validate calls return ok: true with the policy's entitlements.

Scope required: licenses:write (any role except read-only).

Revoke a license

-
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
+    
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
   -H "Authorization: Bearer ks_..." \
   -H "Content-Type: application/json" \
-  -d '{"reason":"customer request"}'
+ -d '{"reason":"customer request"}'

Idempotent. The next online validate from the buyer's app returns reason: revoked.

Scope required: licenses:write.

Find a license by email

-
curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \
-  -H "Authorization: Bearer ks_..."
+
curl "$KS/v1/admin/licenses/search?buyer_email=alice@example.com" \
+  -H "Authorization: Bearer ks_..."

Returns matching licenses (without the license_key field, which is only returned on issue / recover). Use the id for follow-up operations.

Scope required: licenses:read.

Cancel a buyer's subscription

-
# Look up the subscription id first (filter by license_id if you have it)
+    
# 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"}'
+ -d '{"reason":"buyer requested"}'

License stays valid through the current cycle's expires_at. Renewal worker stops issuing new invoices.

Scope required: subscriptions:write.

Free a machine seat

-
curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
+    
curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
   -H "Authorization: Bearer ks_..." \
-  -d '{"reason":"buyer moved devices"}'
+ -d '{"reason":"buyer moved devices"}'

The seat opens up. The buyer's next validate from any machine takes the freed seat.

Scope required: machines:write.

Programmatic tier change (comp upgrade)

-
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
+    
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
   -H "Authorization: Bearer ks_..." \
   -d '{
     "target_policy_slug": "pro",
     "reason": "support resolution"
-  }'
+ }'

Always applies as comp (no invoice) from the admin path. Buyer-initiated paid upgrades go through /v1/upgrade (different endpoint, signed-license auth).

Scope required: licenses:write.

+

Worked example: gate an app behind a license

+

End to end, with nothing but a merchant-onboard 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 Install & setup). This is the minimal true path, nothing more.

+ +

1. On the server. Create the catalog and issue a license. Replace $KS with your Keysat URL and ks_... with your scoped key.

+
# 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"
+}'
+ +

2. In your app. Install the SDK and verify the license offline. verify() returns on success and throws a LicensingError on any bad key; there is no valid boolean.

+
npm install @keysat/licensing-client
+
// app/api/export/route.ts
+import { Verifier, PublicKey, LicensingError } from "@keysat/licensing-client";
+
+const ISSUER_PEM = `-----BEGIN PUBLIC KEY-----
+<paste your public_key_pem here>
+-----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" } });
+}
+ +

3. Confirm the gate. A valid license is accepted; absent and tampered keys are refused. $APP is your running app.

+
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
+ +

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.

+

Webhooks: react to events instead of polling

Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:

@@ -225,11 +324,11 @@ curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \

Verify signatures:

-
import hmac, hashlib
+    
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)
+ return hmac.compare_digest(expected, signature_header)

The header is X-Keysat-Signature. Failed deliveries retry with exponential backoff up to 10 attempts; permanently-failed deliveries land in the DLQ visible at admin UI → Webhooks → Failed.

Designing a robust agent

@@ -251,7 +350,7 @@ def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:

internal_error (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.

Concrete recipe: "Comp a license to anyone who emails support@"

-
import os, requests, imaplib, email
+    
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
+# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key

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.

What's NOT exposed to agents

@@ -297,6 +396,7 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str: Discovering the API Response envelope Common workflows + Worked example Webhooks Designing a robust agent Recipe: comp-license bot diff --git a/integrate.html b/integrate.html index 0ab1aa2..cbaf886 100644 --- a/integrate.html +++ b/integrate.html @@ -109,7 +109,7 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL

Step 2: Verify a license at startup

-

Read the user’s license key from wherever you store it (a file in their data directory, the OS keychain, an env var) and verify it on application start.

+

Read the user’s license key from wherever you store it (a file in their data directory, the OS keychain, an env var) and verify it on application start. In a server-side app the key arrives per request instead: read it from a header you define (for example X-License-Key) or the session, then verify it the same way.

import { Verifier, PublicKey } from '@keysat/licensing-client';
 
@@ -117,83 +117,90 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
   PublicKey.fromPem(ISSUER_PEM)
 );
 
-const result = verifier.verify(licenseKeyFromUser);
+// verify() returns the verified license, or THROWS if the key is missing,
+// malformed, forged, or signed by someone else. Catch it (see step 3).
+const license = verifier.verify(licenseKeyFromUser);
 
-// 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.
-if (result.valid) {
-  app.licensed = true;
-  app.entitlements = result.entitlements;
-}
+// What you do next is up to YOUR business model. The verified payload +// carries the entitlements baked in at issue time. +app.licensed = true; +app.entitlements = license.payload.entitlements; // string[] +// verify() returns Ok(VerifyOk) or Err on a bad key (see step 3). +let license = verifier.verify(&license_key)?; + +app.licensed = true; +app.entitlements = license.payload.entitlements; +# verify() returns the verified license, or RAISES on a bad key (see step 3). +license = verifier.verify(license_key_from_user) -

The verifier returns a result object with the following fields:

+app.licensed = True +app.entitlements = license.payload.entitlements + +

On success, verify() returns a VerifyOk result. There is no valid boolean: an invalid key throws (TS / Python) or returns Err (Rust). See step 3. Field names are camelCase in TS/JS and snake_case in Rust/Python.

- - - - - - - - - + + + + + + +
FieldTypeMeaning
validboolSignature checked, expiry not exceeded.
product_idstringThe product slug this license was issued for.
policy_slugstringWhich policy was active at issue time.
license_idstringUUID of the license; useful for support tickets.
issued_atDateUTC timestamp.
expires_atDate | nullnull for perpetual.
is_trialboolSet by the policy at issue time.
seatsintMax machines (0 = unlimited).
entitlementsSet<string>Feature flags baked into the signed payload.
productIdstringUUID of the product this license was issued for.
licenseIdstringUUID of the license; useful for support tickets.
payload.entitlementsstring[]Feature slugs baked into the signed payload.
payload.issuedAtnumberUnix seconds at issue time.
payload.expiresAtnumberUnix seconds; 0 for perpetual.
payload.isTrialboolSet by the policy at issue time.
payload.isFingerprintBoundboolTrue if the key is bound to one machine.
+
+ +

verify() checks the signature and format, not expiry or revocation. A perpetual license never expires; to reject expired keys offline, compare the payload’s expiresAt to now. Every SDK ships an isExpiredAt/is_expired_at helper for this; TS and Rust also offer a one-call verifyWithTime(key, nowUnixSeconds). Live status (revoked, suspended, seats in use, the policy slug) isn’t in the offline payload; get it from the online validate path below.

+
+

Step 3: Handle errors gracefully

Verification can fail for benign reasons (the user hasn’t pasted a license yet) or hostile ones (someone tampered with a license file). Distinguish them in your UX:

-
try {
-  const result = verifier.verify(licenseKey);
-  if (result.valid) grantAccess(result);
-  else showRenewalPrompt(result.expires_at);
+    
import { LicensingError } from '@keysat/licensing-client';
+
+try {
+  const license = verifier.verify(licenseKey); // throws if not valid
+  grantAccess(license);
 } catch (e) {
-  if (e instanceof SignatureError) showTamperWarning();
-  else if (e instanceof FormatError) showInputError();
+  // Every failure is a LicensingError with a machine-readable .code:
+  // 'bad_signature' (tampered / forged), 'bad_format' or 'bad_encoding'
+  // (garbled input), 'bad_version', 'expired' (only from verifyWithTime).
+  if (e instanceof LicensingError && e.code === 'bad_signature') showTamperWarning();
+  else if (e instanceof LicensingError) showInputError();
   else showGenericError(e);
 }
-