47facc8909
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.
410 lines
24 KiB
HTML
410 lines
24 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: Agent integration</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>
|
|
</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">Integrate the SDK</a>
|
|
<a href="agent.html" class="active">Agent integration</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>
|
|
</div>
|
|
<div class="group">
|
|
<div class="glabel">Project</div>
|
|
<a href="pricing.html">Pricing</a>
|
|
<a href="license.html">License</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 · Agent integration</div>
|
|
<h1>Agent integration guide.</h1>
|
|
<p class="lead">How to build agents, bots, and automation that operate a Keysat instance. Keysat was designed from the start to be agent-friendly: the admin API uses plain HTTP + JSON with Bearer-token auth, an OpenAPI 3.1 spec drives discovery, scoped API keys grant least-privilege access without exposing the master credential, errors carry stable machine-readable codes, and webhooks let an agent react to events instead of polling.</p>
|
|
<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 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)
|
|
curl -X POST https://your-keysat-host/v1/admin/api-keys \
|
|
-H "Authorization: Bearer $MASTER_ADMIN_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"label":"Support bot","role":"support"}'
|
|
# Response includes `token: ks_...`. Save it. It's only shown once.
|
|
|
|
# 3. Use the scoped key
|
|
curl https://your-keysat-host/v1/admin/licenses?status=active \
|
|
-H "Authorization: Bearer ks_..."</pre>
|
|
|
|
<h2 id="auth">Authentication</h2>
|
|
<p>All admin endpoints use HTTP Bearer auth:</p>
|
|
<pre class="code">Authorization: Bearer <token></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 → Show credentials on first install). Full access to every endpoint. This is the operator's credential. Don't hand it to agents.</p>
|
|
|
|
<p><strong>Scoped API keys</strong>: additional tokens generated in admin UI → Settings → API keys. Each carries a role that bounds what it can do. Format: <code>ks_<43 chars></code>. Operators can revoke any scoped key from the same UI; revoked tokens stop working immediately.</p>
|
|
|
|
<h3>Role to scope mapping</h3>
|
|
<table>
|
|
<thead><tr><th>Role</th><th>What it can do</th></tr></thead>
|
|
<tbody>
|
|
<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>
|
|
<p>Endpoints that touch settings (operator name, payment provider connections, self-license activation, scoped API key management) always require the master admin key. A <code>full-admin</code> scoped key cannot, for example, generate another scoped key. That's a self-defeating elevation path.</p>
|
|
|
|
<h2 id="discovery">Discovering the API</h2>
|
|
<p>Two complementary discovery mechanisms.</p>
|
|
|
|
<h3>OpenAPI 3.1 spec</h3>
|
|
<p><code>GET /v1/openapi.json</code>. Unauthenticated. Returns a curated spec covering the agent-relevant subset of endpoints. Use this with:</p>
|
|
<ul>
|
|
<li><strong>OpenAI Custom GPTs</strong>: paste the URL as an Action.</li>
|
|
<li><strong>OpenAI Assistants / Functions</strong>: feed the spec to tool definition generators.</li>
|
|
<li><strong>Claude tool use</strong>: derive your <code>tools</code> array from the spec; Claude Code agents can <code>WebFetch</code> the spec at runtime.</li>
|
|
<li><strong>LangChain / AutoGen / Smolagents</strong>: use their OpenAPI loaders.</li>
|
|
<li><strong>Code generation</strong>: <code>openapi-generator-cli generate -i /v1/openapi.json -g python -o ./client</code>.</li>
|
|
</ul>
|
|
<p>The spec is a stable agent surface, not auto-derived from handler signatures. We commit to keeping documented endpoints and field shapes stable across minor releases.</p>
|
|
|
|
<h3>Embedded endpoint listing</h3>
|
|
<p>This guide's <a href="#workflows">Common workflows</a> section below covers the most common agent tasks with copy-paste examples.</p>
|
|
|
|
<h2 id="envelope">Response envelope conventions</h2>
|
|
<p>Every error response uses the same JSON envelope:</p>
|
|
<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"
|
|
}</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>
|
|
<table>
|
|
<thead><tr><th>HTTP</th><th><code>error</code> code</th><th>When</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>400</td><td><code>bad_request</code></td><td>Malformed body, missing required field, invalid enum value</td></tr>
|
|
<tr><td>401</td><td><code>unauthorized</code></td><td>No <code>Authorization: Bearer</code> header</td></tr>
|
|
<tr><td>403</td><td><code>forbidden</code></td><td>Wrong token, revoked scoped key, role doesn't grant required scope</td></tr>
|
|
<tr><td>404</td><td><code>not_found</code></td><td>Resource id doesn't exist</td></tr>
|
|
<tr><td>409</td><td><code>conflict</code></td><td>Slug collision, delete-with-references blocked, etc.</td></tr>
|
|
<tr><td>402</td><td><code>tier_cap</code></td><td>Operator's self-tier doesn't include the required entitlement</td></tr>
|
|
<tr><td>429</td><td><code>rate_limited</code></td><td>Rate limit hit (e.g. /v1/recover, /v1/validate)</td></tr>
|
|
<tr><td>502</td><td><code>upstream_error</code></td><td>BTCPay / Zaprite call failed</td></tr>
|
|
<tr><td>503</td><td><code>service_unavailable</code> / <code>btcpay_not_configured</code></td><td>Provider not yet connected</td></tr>
|
|
<tr><td>500</td><td><code>internal_error</code></td><td>Bug. Includes a trace id in logs; report it.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>Validate response</h3>
|
|
<p><code>POST /v1/validate</code> is the one endpoint that returns 200 in all cases. Inspect <code>ok</code> + <code>reason</code>:</p>
|
|
<table>
|
|
<thead><tr><th><code>reason</code></th><th>Meaning</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>bad_signature</code></td><td>Signature doesn't verify against the trust-root pubkey</td></tr>
|
|
<tr><td><code>not_found</code></td><td>License key not in the daemon's DB</td></tr>
|
|
<tr><td><code>revoked</code></td><td>Operator revoked it</td></tr>
|
|
<tr><td><code>suspended</code></td><td>Operator suspended it (reversible)</td></tr>
|
|
<tr><td><code>expired</code></td><td>Past <code>expires_at</code></td></tr>
|
|
<tr><td><code>fingerprint_mismatch</code></td><td>Different machine than the one bound on first activate</td></tr>
|
|
<tr><td><code>product_mismatch</code></td><td>License is for a different product than the caller asserted</td></tr>
|
|
<tr><td><code>machine_cap_exceeded</code></td><td>Activating this fingerprint would exceed <code>max_machines</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<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 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",
|
|
"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 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"}'</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 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 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"}'</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 class="code">curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
|
|
-H "Authorization: Bearer ks_..." \
|
|
-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 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"
|
|
}'</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 & 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-----
|
|
<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" } });
|
|
}</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>
|
|
<thead><tr><th>Event</th><th>Fires on</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>license.issued</code></td><td>New license minted (purchase, comp, redeem)</td></tr>
|
|
<tr><td><code>license.revoked</code> / <code>license.suspended</code> / <code>license.unsuspended</code></td><td>Admin operations</td></tr>
|
|
<tr><td><code>license.tier_changed</code></td><td>Tier upgrade/downgrade applied</td></tr>
|
|
<tr><td><code>invoice.paid</code></td><td>A BTCPay / Zaprite invoice settled</td></tr>
|
|
<tr><td><code>subscription.renewal_pending</code></td><td>Renewal worker created a fresh invoice</td></tr>
|
|
<tr><td><code>subscription.renewal_skipped</code></td><td>Renewal skipped (e.g. policy archived)</td></tr>
|
|
<tr><td><code>subscription.cancelled</code></td><td>Buyer or admin cancelled</td></tr>
|
|
<tr><td><code>subscription.lapsed</code></td><td>Past-due grace expired</td></tr>
|
|
<tr><td><code>machine.activated</code></td><td>First validate from a new fingerprint</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p>Verify signatures:</p>
|
|
<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)</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>
|
|
<p>A few patterns that work well in practice.</p>
|
|
|
|
<h3>Idempotency</h3>
|
|
<p>The daemon's mutation endpoints are idempotent where they can be. Revoke, suspend, unsuspend, archive, unarchive, subscription cancel. All return success on the second call without changing state. Your agent can safely retry on network errors.</p>
|
|
|
|
<h3>Pagination</h3>
|
|
<p>List endpoints return up to ~100 rows by default. Use <code>?limit=N</code> and <code>?offset=N</code> for larger result sets. The OpenAPI spec documents the limits per endpoint.</p>
|
|
|
|
<h3>Rate limits</h3>
|
|
<p>The admin endpoints have no per-IP rate limit today. Operators are trusted. The public endpoints (<code>/v1/validate</code>, <code>/v1/recover</code>) are rate-limited per client IP (10/min for <code>/recover</code>; <code>/validate</code> is unlimited but a reasonable agent calls it once per app boot + once per hour).</p>
|
|
|
|
<h3>Master key handling</h3>
|
|
<p>If your automation needs <code>full-admin</code> because it touches operator-only operations (creating other API keys, changing payment providers), use the master key from a secret manager. If it can stay within license / product / policy operations, <strong>always use a scoped key</strong>. Operators can revoke a compromised scoped key without rotating the master credential.</p>
|
|
|
|
<h3>Backoff on 5xx</h3>
|
|
<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 class="code">import os, requests, imaplib, email
|
|
|
|
KS = os.environ["KEYSAT_URL"]
|
|
TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key
|
|
|
|
def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
|
|
r = requests.post(
|
|
f"{KS}/v1/admin/licenses",
|
|
headers={"Authorization": f"Bearer {TOKEN}"},
|
|
json={
|
|
"product_slug": product_slug,
|
|
"policy_slug": "default",
|
|
"buyer_email": buyer_email,
|
|
"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</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>
|
|
<p>Some operations are deliberately operator-only and not accessible to any scoped key, including <code>full-admin</code>:</p>
|
|
<ul>
|
|
<li>Generating / revoking scoped API keys (<code>/v1/admin/api-keys</code>)</li>
|
|
<li>Connecting / disconnecting payment providers</li>
|
|
<li>Setting the operator name</li>
|
|
<li>Activating the self-license (<code>/v1/admin/self-license</code>)</li>
|
|
<li>Resetting the analytics install_uuid</li>
|
|
<li>Changing the web UI password (StartOS Action only)</li>
|
|
</ul>
|
|
<p>These all require the master <code>KEYSAT_ADMIN_API_KEY</code>. The reasoning: an agent that can rotate its own credentials, connect arbitrary payment processors, or change the operator identity is no longer bounded by the role it was given.</p>
|
|
|
|
<h2 id="feedback">Help us improve this guide</h2>
|
|
<p>The OpenAPI spec is the source of truth for the API surface. This guide is a hand-curated overlay focused on the workflows we've seen agents actually need. If you're building something the spec covers but this guide doesn't make obvious, open an issue at <a href="https://github.com/keysat-xyz/keysat">github.com/keysat-xyz/keysat</a> with the workflow shape and we'll add it.</p>
|
|
</main>
|
|
|
|
<aside class="toc">
|
|
<div class="label">On this page</div>
|
|
<a href="#quick-start">Quick start</a>
|
|
<a href="#auth">Authentication</a>
|
|
<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>
|
|
<a href="#not-exposed">Not exposed to agents</a>
|
|
</aside>
|
|
</div>
|
|
|
|
<script src="docs.js"></script>
|
|
</body>
|
|
</html>
|