Files
keysat-docs/agent.html
T

445 lines
28 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 &amp; 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 &amp; 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 (admin/licenses requires a product_id)
curl "https://your-keysat-host/v1/admin/licenses?product_id=&lt;uuid&gt;" \
-H "Authorization: Bearer ks_..."</pre>
<h2 id="auth">Authentication</h2>
<p>All admin endpoints use HTTP Bearer auth:</p>
<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>
<p><strong>Scoped API keys</strong>: additional tokens generated in admin UI &rarr; Settings &rarr; API keys. Each carries a role that bounds what it can do. Format: <code>ks_&lt;43 chars&gt;</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, 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>
<h3>A-la-carte extra scopes</h3>
<p>An operator can grant a single sensitive capability on top of a role when minting a key (admin UI &rarr; Settings &rarr; API keys). The only one today is <code>payment_providers:write</code>, which lets a scoped key connect a BTCPay payment provider, but <em>only on a sandbox daemon and only for a non-mainnet network</em> (see <a href="#connect-btcpay">Connect BTCPay programmatically</a>). It belongs to no role by default (not even <code>full-admin</code>): a credential that can repoint where settlement lands is a fund-redirection key, so on a production daemon connecting a provider always stays master-only.</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 '{
"to_policy_slug": "pro",
"skip_payment": true,
"reason": "support resolution"
}'</pre>
<p>With <code>skip_payment: true</code> this applies as a comp (no invoice). Omit it (defaults to false) and the admin path behaves like the buyer path: it creates an invoice for the prorated charge and returns the checkout URL. Buyer-initiated paid upgrades go through <code>/v1/upgrade</code> (different endpoint, signed-license auth).</p>
<p><em>Master admin key required.</em></p>
<h3 id="connect-btcpay">Connect BTCPay programmatically (sandbox)</h3>
<p>On a <strong>sandbox</strong> daemon (<code>KEYSAT_SANDBOX_MODE=1</code>), a scoped key carrying <code>payment_providers:write</code> can connect a BTCPay store over the API with no browser step, as long as the store settles on a <strong>non-mainnet</strong> network (regtest / testnet / signet). On a production daemon, or for a mainnet store, connect stays master-only. This is the path a delegated setup agent uses to stand up a disposable test instance end to end. You need a BTCPay API key for the target store (the operator's BTCPay access, delegated to you) carrying the same store and invoice permissions the browser flow grants (see <a href="install.html#connect-btcpay">Install &amp; setup</a>): the store-settings permissions complete the connect, and the invoice permissions let settled purchases issue licenses.</p>
<pre class="code"># 1. Start the connect. Returns a one-time `state` token + the BTCPay authorize URL.
curl -X POST $KS/v1/admin/btcpay/connect \
-H "Authorization: Bearer ks_..."
# -> { "authorize_url": "https://btcpay.example/api-keys/authorize?...", "state": "STATE", "merchant_profile_id": "..." }
# 2. Complete the connect by handing Keysat your BTCPay store API key, keyed by the
# `state` token (no Authorization header here: the single-use state token is the tie).
# A human approving in the browser at authorize_url reaches this same callback.
# Keysat resolves the store's network here and returns a 4xx if it is mainnet.
curl "$KS/v1/btcpay/authorize/callback?state=STATE&apiKey=BTCPAY_STORE_API_KEY"
# -> "BTCPay connected successfully." (HTTP 4xx with an error page if the gate refuses)
# 3. Confirm.
curl $KS/v1/admin/btcpay/status -H "Authorization: Bearer ks_..."
# -> { "connected": true, "store_id": "...", "base_url": "...", ... }
# 4. Create a buyer checkout for one of your paid products (public endpoint, no auth).
curl -X POST $KS/v1/purchase -H "Content-Type: application/json" -d '{
"product": "acme-reports", "policy_slug": "pro", "buyer_email": "buyer@example.com"
}'
# -> { "invoice_id": "...", "checkout_url": "https://btcpay.example/i/...", ... }
# 5. The buyer pays checkout_url; poll until settlement signs a license.
curl $KS/v1/purchase/INVOICE_ID
# -> { "status": "settled", "license_key": "LIC1-..." }</pre>
<p>The <code>license_key</code> a settled purchase returns carries the policy's entitlements, so it unlocks the same gate the <a href="#worked-example">worked example</a> builds. That is the full money path: connect once, then every buyer purchase self-issues a signed license.</p>
<p>Scope the BTCPay key to exactly the store you want to connect: Keysat attaches the first store the key can see. If the store's network cannot be confirmed as non-mainnet (mainnet, a Lightning-only store, or any detection failure), the callback fails closed with a 4xx and nothing is persisted. Disconnect (<code>POST /v1/admin/btcpay/disconnect</code>) is always master-only.</p>
<p><em>Scope required: <code>payment_providers:write</code>, on a sandbox daemon, for a non-mainnet store. The master key may connect any network on any daemon.</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>
<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> allows a 60/min burst per client IP, which a reasonable agent stays well under by calling 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>Disconnecting payment providers, and connecting a provider on a production daemon. (A scoped key with <code>payment_providers:write</code> may connect a <em>non-mainnet</em> provider on a <em>sandbox</em> daemon only; see <a href="#connect-btcpay">Connect BTCPay programmatically</a>.)</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 require the master <code>KEYSAT_ADMIN_API_KEY</code>. The reasoning: an agent that can rotate its own credentials, repoint settlement to an arbitrary wallet, or change the operator identity is no longer bounded by the role it was given. The one deliberate carve-out is sandbox payment-provider connect (above): bounded to a sandbox daemon and a non-mainnet network, it lets a delegated agent stand up a disposable test instance end to end without ever touching mainnet funds or the master key.</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>