Keysat Docs
Get started · Agent integration

Agent integration guide.

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.

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
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_..."

Authentication

All admin endpoints use HTTP Bearer auth:

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.

Scoped API keys: additional tokens generated in admin UI → Settings → API keys. Each carries a role that bounds what it can do. Format: ks_<43 chars>. Operators can revoke any scoped key from the same UI; revoked tokens stop working immediately.

Role to scope mapping

RoleWhat it can do
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.
full-adminEvery scope. Equivalent to the master key for most endpoints.

Endpoints that touch settings (operator name, payment provider connections, self-license activation, scoped API key management) always require the master admin key. A full-admin scoped key cannot, for example, generate another scoped key. That's a self-defeating elevation path.

Discovering the API

Two complementary discovery mechanisms.

OpenAPI 3.1 spec

GET /v1/openapi.json. Unauthenticated. Returns a curated spec covering the agent-relevant subset of endpoints. Use this with:

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.

Embedded endpoint listing

This guide's Common workflows section below covers the most common agent tasks with copy-paste examples.

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

HTTPerror codeWhen
400bad_requestMalformed body, missing required field, invalid enum value
401unauthorizedNo Authorization: Bearer header
403forbiddenWrong token, revoked scoped key, role doesn't grant required scope
404not_foundResource id doesn't exist
409conflictSlug collision, delete-with-references blocked, etc.
402tier_capOperator's self-tier doesn't include the required entitlement
429rate_limitedRate limit hit (e.g. /v1/recover, /v1/validate)
502upstream_errorBTCPay / Zaprite call failed
503service_unavailable / btcpay_not_configuredProvider not yet connected
500internal_errorBug. Includes a trace id in logs; report it.

Validate response

POST /v1/validate is the one endpoint that returns 200 in all cases. Inspect ok + reason:

reasonMeaning
bad_signatureSignature doesn't verify against the trust-root pubkey
not_foundLicense key not in the daemon's DB
revokedOperator revoked it
suspendedOperator suspended it (reversible)
expiredPast expires_at
fingerprint_mismatchDifferent machine than the one bound on first activate
product_mismatchLicense is for a different product than the caller asserted
machine_cap_exceededActivating this fingerprint would exceed max_machines

Common workflows

Issue a comp license

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

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 \
  -H "Authorization: Bearer ks_..." \
  -H "Content-Type: application/json" \
  -d '{"reason":"refund issued"}'

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_..."

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)
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"}'

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 \
  -H "Authorization: Bearer ks_..." \
  -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 \
  -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.

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:

EventFires on
license.issuedNew license minted (purchase, comp, redeem)
license.revoked / license.suspended / license.unsuspendedAdmin operations
license.tier_changedTier upgrade/downgrade applied
invoice.paidA BTCPay / Zaprite invoice settled
subscription.renewal_pendingRenewal worker created a fresh invoice
subscription.renewal_skippedRenewal skipped (e.g. policy archived)
subscription.cancelledBuyer or admin cancelled
subscription.lapsedPast-due grace expired
machine.activatedFirst validate from a new fingerprint

Verify signatures:

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)

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

A few patterns that work well in practice.

Idempotency

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.

Pagination

List endpoints return up to ~100 rows by default. Use ?limit=N and ?offset=N for larger result sets. The OpenAPI spec documents the limits per endpoint.

Rate limits

The admin endpoints have no per-IP rate limit today. Operators are trusted. The public endpoints (/v1/validate, /v1/recover) are rate-limited per client IP (10/min for /recover; /validate is unlimited but a reasonable agent calls it once per app boot + once per hour).

Master key handling

If your automation needs full-admin 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, always use a scoped key. Operators can revoke a compromised scoped key without rotating the master credential.

Backoff on 5xx

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

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,
            "buyer_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

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

Some operations are deliberately operator-only and not accessible to any scoped key, including full-admin:

These all require the master KEYSAT_ADMIN_API_KEY. 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.

Help us improve this guide

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 github.com/keysat-xyz/keysat with the workflow shape and we'll add it.