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.
# 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_..."
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.
read-onlylicense-issuerread-only scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes.supportlicense-issuer scopes + cancel subscriptions + force-deactivate machines.merchant-onboardread-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 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.
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.
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.
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).
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.
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.
# 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.
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.
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.
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.
+Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:
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.
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.
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.
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.
| Field | Type | Meaning |
|---|---|---|
valid | bool | Signature checked, expiry not exceeded. |
product_id | string | The product slug this license was issued for. |
policy_slug | string | Which policy was active at issue time. |
license_id | string | UUID of the license; useful for support tickets. |
issued_at | Date | UTC timestamp. |
expires_at | Date | null | null for perpetual. |
is_trial | bool | Set by the policy at issue time. |
seats | int | Max machines (0 = unlimited). |
entitlements | Set<string> | Feature flags baked into the signed payload. |
productId | string | UUID of the product this license was issued for. |
licenseId | string | UUID of the license; useful for support tickets. |
payload.entitlements | string[] | Feature slugs baked into the signed payload. |
payload.issuedAt | number | Unix seconds at issue time. |
payload.expiresAt | number | Unix seconds; 0 for perpetual. |
payload.isTrial | bool | Set by the policy at issue time. |
payload.isFingerprintBound | bool | True if the key is bound to one machine. |
verify() checks the signature and format, not expiry or revocation. A perpetual license never expires; to reject expired keys offline, compare the payload’s expiresAt to now. Every SDK ships an isExpiredAt/is_expired_at helper for this; TS and Rust also offer a one-call verifyWithTime(key, nowUnixSeconds). Live status (revoked, suspended, seats in use, the policy slug) isn’t in the offline payload; get it from the online validate path below.
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); }-