Compare commits

..

20 Commits

Author SHA1 Message Date
Keysat 6011d4fc2b Fix Rust import path in integration docs
The Rust code panes used 'use licensing_client::...' but the crate is
keysat-licensing-client (module keysat_licensing_client). Correct both panes
so copy-pasted snippets compile.
2026-06-18 14:46:13 -05:00
Keysat f99d4a4998 Document merchant profiles (multi-business / multi-provider) in docs
Add a Merchant profiles concept section to index.html: the default profile, per-profile branding and payment accounts, product routing, and the Pro/Patron multi-business capability. Wire it into the Concepts nav across all pages and the index TOC. Conforms to the brand contract (design-checker: compliant).
2026-06-18 12:19:13 -05:00
Keysat 5df25df375 Remove false claim that Keysat emails licenses to buyers
Keysat has no email send path and none is planned; the receipt page is the delivery mechanism. install.html Step 8 incorrectly told operators the license is emailed to the buyer.
2026-06-18 12:00:09 -05:00
Keysat 470f3a6980 Resequence install steps so the admin UI opens before operator-name and BTCPay 2026-06-17 15:41:15 -05:00
Keysat 2a0e179c43 Fix change-tier API example and move install steps to the admin UI 2026-06-17 15:24:55 -05:00
Keysat 1d87d6d889 docs(agent): show the buyer-pays money path in connect-btcpay
Extend the programmatic connect snippet with the checkout + poll steps
(POST /v1/purchase, GET /v1/purchase/{id}) and note the purchased license
carries the policy entitlements, so it unlocks the same gate the worked
example builds. Ties connect, buyer-pays, and gate-unlock together in one
place; previously that money path lived only in the OpenAPI spec.
2026-06-17 12:03:16 -05:00
Keysat 7e6f752462 Document agent BTCPay connect (sandbox, scoped key)
Add the "Connect BTCPay programmatically" agent workflow and a payment_providers:write
extra-scope note to agent.html; correct the "not exposed to agents" section to the
accurate gate (scoped connect is sandbox + non-mainnet only; disconnect and
production/mainnet stay master-only). Fix the BTCPay permission list in install.html to
the five permissions the daemon actually requests, and point operators at the agent path.
2026-06-17 09:32:21 -05:00
Keysat 47facc8909 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.
2026-06-16 22:47:59 -05:00
Keysat 3f1fbe0f3b Remove refund copy from public docs
Keysat has no refund functionality — refunds are handled out-of-band in the
payment processor (the v0.3 revoke-on-refund webhook hook is currently a
no-op). Drop refund mentions so the docs do not describe a flow the daemon
does not implement.
2026-06-13 06:58:12 -05:00
Keysat 23681bc05e Fix revocation docs: use POST /v1/validate, not a phantom license-status endpoint
GET /v1/licenses/{id}/status does not exist. Revocation is checked via
POST /v1/validate, which returns ok:false / reason:"revoked".
2026-06-13 06:40:08 -05:00
Keysat 87fd4f32e3 Docs polish: active-pill sync, license-sidebar bug fix, pricing standardized, ~70 em-dashes removed
- docs.js (new): sync sidebar .active pill with location.hash on load, click, and hashchange so in-page anchor links (Architecture, Discount codes, Backups, etc.) update the pill instead of leaving it stuck on whatever was statically marked
- Wire docs.js into every page just before </body>
- license.html: sidebar Project/Operate order matches every other page (Project first)
- pricing.html: rewritten to use the standard docs layout (full sidebar groups, prose main, breadcrumb) instead of a one-off shell that felt detached from the rest of the docs
- Reference section: remove Admin API + SDKs anchor links (they masqueraded as separate pages but just scrolled within integrate.html); Wire format stands alone
- Pricing copy: Zaprite reframed as "expanded payment options including card payment capabilities", "shipping in v0.3" removed (it shipped), Patron rephrased as perpetual (never expires or renews)
- "Toggling inactive" cap-evasion language replaced — admin UI exposes delete only, no soft-disable affordance for products
- ~70 em-dashes removed across 8 pages using a small pattern set (elaboration→period, list-intro→colon, tight clarification→comma, parentheticals→parens). Decorative stamp ornaments and references to actual third-party UI labels are kept verbatim.
2026-05-12 09:25:57 -05:00
Keysat 348a0b9f13 docs: add /license page + Project sidebar group across all pages
- New license.html: plain-English summary of the Keysat
  Source-Available License 1.0 (daemon) and MIT (SDKs +
  template). TL;DR table, "what you can do / can't do" lists,
  contribution-flow explainer, links to each repo's LICENSE
  file on GitHub. Anchor sections + on-this-page TOC.
- New "Project" sidebar group (Pricing + License) inserted
  above the existing Operate group on every docs page so the
  /license page is discoverable from anywhere in the docs.
2026-05-11 21:51:54 -05:00
Keysat 9a881f5f06 integrate.html: Rust install snippet uses keysat-licensing-client 0.3 (now on crates.io) 2026-05-11 21:38:37 -05:00
Keysat fe96fe2091 operate.html: note Patron direct-support path in getting-help 2026-05-11 20:04:06 -05:00
Keysat 23aa121afb Docs sweep: align install / integrate / operate / pricing / wire-format
Five-page sweep to match the current daemon state.

install.html:
- Step 6 (first product): "Price (sats)" → reflects the
  currency picker (sats / USD / EUR) shipped in migration 0010.
- Step 7 (first policy): drop the "default slug is consumed by
  public flow" myth — buy page renders a tier picker for any
  product with ≥2 public policies. Add references to entitlements
  catalog, hide-on-buy toggles, marketing bullets, recurring
  subscriptions, and the drag-to-reorder policy grid.

integrate.html:
- "Three official SDKs" → "Four official SDKs" + Go tab + install
  snippet. Notes the daemon's cross-check fixtures assert
  byte-for-byte parity across all four.
- Admin API table: drop "by npub" from the licenses search
  description (backend supports it; UI hasn't surfaced it yet
  since the purchase flow doesn't capture npubs).

operate.html:
- Backups section: drop the imaginary `/data/issuer-key.pem`
  file — the signing keypair lives in the `server_keys` SQLite
  table, not in a PEM file on disk. Mention the self-license
  file path (`/data/keysat-license.txt`).
- Rotation: drop the "v0.1 doesn't support / v0.2 will" framing;
  rotation isn't on the v0.2 / v0.3 roadmap and the v0.1 caveat
  is misleading. Update steps to reflect SQLite-as-keystore.
- Webhook troubleshooting: point at the dedicated
  Webhooks → Failed (DLQ) view rather than the audit log.

pricing.html:
- Creator: 21,000 sats one-time → Free forever (matches actual
  master Keysat configuration).
- Pro: 250,000 sats/yr → 100,000 sats/yr (recurring). Note
  recurring + tier upgrades have shipped; only Zaprite remains
  v0.3.
- Patron: 500,000 sats/yr → 250,000 sats one-time perpetual.
  Differentiation rewritten: perpetual license + direct 1:1
  support (not just "Pro with a badge").
- Active discount-code cap: 5 → 10 (real cap).
- New "Prices shown are a snapshot" note pointing at the
  canonical live source (keysat.xyz#tiers + the buy page).
- Updated unlicensed-caps line to show 5/5/10 with units.

wire-format.html:
- Replace the entirely-fabricated "KS-base32-blob with KSAT magic
  bytes" layout with the actual LIC1 envelope:
  `LIC1-<base32 payload>-<base32 signature>` split on dashes.
- Document BOTH payload versions: v1 (legacy 74-byte fixed) and
  v2 (current default, 83-byte head + variable entitlements
  table). Field offsets, flag bits, signature scope all match
  the daemon source.
- Drop the bogus Crockford-base32 + dash-grouping sections —
  the daemon uses RFC 4648 base32 with single-dash structure
  separators, not grouped-dashes for readability.
- Drop the fabricated hex-dump worked example.
- Porting section now points at `licensing-service/tests/crosscheck/`
  (the actual fixtures location) instead of a Python-SDK path.
- Versioning policy: clarify envelope-tag vs payload-version
  cadence.
2026-05-11 19:30:47 -05:00
Keysat 95a11666d7 Docs index: refresh against current daemon state
Nine wording fixes:

- Hero lede: drop "Bitcoin payment via BTCPay" → "payment via
  BTCPay" (matches landing-page de-emphasis); split the dense
  one-paragraph lede into two.
- Products: "price in sats" → "price (sats / USD / EUR)";
  introduce the entitlements catalog concept.
- Policies table: add is_recurring + renewal_period_days,
  marketing_bullets, hidden_entitlements. Fix "default slug is
  canonical" myth — multi-tier ladders (Basic / Pro / Max) are
  first-class and the buy page renders a tier picker for products
  with 2+ public policies. Note tier_rank + drag-and-drop ordering.
  Split "private policies" out as a paragraph.
- Discounts: add set_price as a 4th kind. Note discount_currency
  on fixed-amount codes. Add multi-policy scope. Add featured
  / launch-special section (ribbon, auto-apply, pre-filled input).
- Revocation: "v0.2 will ship recurring renewals" → past-tense.
  Recurring is shipped.
- New "Operator tiers" section: explains Keysat self-licenses
  (Creator / Pro / Patron), notes caps are enforce-on-create
  (existing rows grandfathered), and lists four canonical sources
  for the live tier list (keysat.xyz, pricing.html, the public
  /v1/products/keysat/policies endpoint, the admin /v1/admin/tier
  endpoint). "As of this writing" framing for the current cap
  values so they don't go stale silently.
- TOC: add #operator-tiers anchor.

Pricing.html, install.html, integrate.html, wire-format.html,
operate.html — not touched; this is the introduction page only.
A separate pass should audit those too.
2026-05-11 18:53:40 -05:00
Keysat 8e55b6ee8b Drop "What's coming" section now that v0.3 features have shipped
Recurring subscriptions and Zaprite payments — the two v0.3
features the section advertised as upcoming — both shipped in the
v0.2 line and gate on the recurring_billing / card_payments
entitlements as planned. Section copy was stale and confusing.
2026-05-11 17:48:50 -05:00
Keysat 9e4c36c05b Agent integration: new docs page + sidebar entry across all docs
Ports the in-repo KEYSAT_AGENT_GUIDE.md into the docs site as a
first-class page rather than linking out to a raw markdown file on
GitHub. The page covers authentication, scoped API keys, OpenAPI
discovery, error envelope conventions, common workflows (issue /
revoke / find / cancel / change-tier / free-machine), webhook
event types + signature verification, robust-agent patterns, a
"comp-license-via-email" recipe, and the operator-only
operations that aren't exposed to any scoped key.

Sidebar gains an "Agent integration" entry under Get started on
every page (index, install, integrate, wire-format, operate, agent
itself). Docs index "These docs cover" + "Where to next" grids
each gain a third card pointing at the agent guide so it's
discoverable from the introduction page even for visitors who
don't scan the sidebar.
2026-05-11 17:48:50 -05:00
Keysat 11760cc295 Drop redundant top-right nav from docs pages
The left sidebar already covers Install / Integrate / Wire format /
Operate across its four groups (Get started, Concepts, Reference,
Operate). The top-right nav duplicated those links one level up. On
mobile it was already hidden via @media rule, so the sidebar was
always the canonical navigation. Now it's the only navigation —
cleaner topbar, no duplication.
2026-05-11 09:59:19 -05:00
Keysat 19a969f797 Topnav: brand logo links to keysat.xyz; drop redundant Marketing entry
Standard docs-site convention: top-left brand goes to the marketing
home, the 'Docs' badge next to it signals you're in the docs section.
The separate 'Marketing' nav item is no longer needed once the brand
itself handles that link.
2026-05-11 09:57:36 -05:00
10 changed files with 1113 additions and 388 deletions
+1 -2
View File
@@ -17,8 +17,7 @@ This repo is a static HTML site. No build step. The deployed version lives at
own software. Code examples for the TypeScript, Python, and Rust SDKs; own software. Code examples for the TypeScript, Python, and Rust SDKs;
entitlement-gating patterns; offline verification. entitlement-gating patterns; offline verification.
- **[operate.html](./operate.html)** — Day-to-day operations. Managing - **[operate.html](./operate.html)** — Day-to-day operations. Managing
licenses, suspending / revoking, search, audit log, discount codes, refund licenses, suspending / revoking, search, audit log, discount codes.
flows.
- **[wire-format.html](./wire-format.html)** — Specification of the signed - **[wire-format.html](./wire-format.html)** — Specification of the signed
license key format (LIC1 envelope, base32 alphabet, Ed25519 signature license key format (LIC1 envelope, base32 alphabet, Ed25519 signature
scheme). Useful for porting the SDK to a new language. scheme). Useful for porting the SDK to a new language.
+445
View File
@@ -0,0 +1,445 @@
<!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#merchant-profiles">Merchant profiles</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>
+49
View File
@@ -0,0 +1,49 @@
// Shared docs-site behaviors. Loaded at the bottom of every page.
//
// Sidebar active-pill sync: the .active class on sidebar links is
// baked into each HTML file as a baseline (page-level link only),
// which means clicking an in-page anchor link (e.g. "Discount codes"
// → index.html#discounts) leaves the pill stuck on whatever the
// statically-marked page link was. This script keeps the active
// state in sync with the current URL hash so the pill follows
// what the user clicked.
(function () {
var sidebarLinks = Array.prototype.slice.call(
document.querySelectorAll('aside.side a')
);
if (!sidebarLinks.length) return;
var currentFile = (location.pathname.split('/').pop() || 'index.html');
function findActive(hash) {
var desired = hash ? (currentFile + hash) : currentFile;
var match = null;
sidebarLinks.forEach(function (a) {
if (a.getAttribute('href') === desired) match = a;
});
if (match) return match;
// Fallback: bare current-file link when no hash matches
sidebarLinks.forEach(function (a) {
if (a.getAttribute('href') === currentFile) match = a;
});
return match;
}
function setActive(link) {
if (!link) return;
sidebarLinks.forEach(function (a) { a.classList.remove('active'); });
link.classList.add('active');
}
setActive(findActive(location.hash));
window.addEventListener('hashchange', function () {
setActive(findActive(location.hash));
});
sidebarLinks.forEach(function (a) {
a.addEventListener('click', function () {
setActive(a);
});
});
})();
+87 -25
View File
@@ -3,22 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs Introduction</title> <title>Keysat Docs: Introduction</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css"> <link rel="stylesheet" href="docs.css">
</head> </head>
<body> <body>
<div class="topnav"> <div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a> <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> <span class="docs-tag">Docs</span>
<nav>
<a href="install.html">Install</a>
<a href="integrate.html">Integrate</a>
<a href="wire-format.html">Wire format</a>
<a href="operate.html">Operate</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div> </div>
<div class="layout"> <div class="layout">
@@ -28,19 +21,24 @@
<a href="index.html" class="active">Introduction</a> <a href="index.html" class="active">Introduction</a>
<a href="install.html">Install &amp; setup</a> <a href="install.html">Install &amp; setup</a>
<a href="integrate.html">Integrate the SDK</a> <a href="integrate.html">Integrate the SDK</a>
<a href="agent.html">Agent integration</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Concepts</div> <div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a> <a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a> <a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#merchant-profiles">Merchant profiles</a>
<a href="index.html#discounts">Discount codes</a> <a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a> <a href="index.html#revocation">Revocation strategy</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Reference</div> <div class="glabel">Reference</div>
<a href="wire-format.html">Wire format</a> <a href="wire-format.html">Wire format</a>
<a href="integrate.html#api">Admin API</a> </div>
<a href="integrate.html#sdks">SDKs</a> <div class="group">
<div class="glabel">Project</div>
<a href="pricing.html">Pricing</a>
<a href="license.html">License</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Operate</div> <div class="glabel">Operate</div>
@@ -53,7 +51,8 @@
<main class="prose"> <main class="prose">
<div class="crumb">Get started · Introduction</div> <div class="crumb">Get started · Introduction</div>
<h1>Welcome to Keysat.</h1> <h1>Welcome to Keysat.</h1>
<p class="lead">Keysat lets independent software creators sell their work on their own terms. You ship software open source, closed source, free / paid versions, whatever fits and Keysat handles the buy page, the Bitcoin payment via BTCPay, and a signed license for each buyer. How you use that license inside your software is up to you: a one-time purchase to unlock the whole app, a free + paid split with specific paid features, a tip-jar style supporter badge — all legitimate. The licensing layer is a primitive, not a script.</p> <p class="lead">Keysat lets independent software creators sell their work on their own terms. You ship software (open source, closed source, free / paid versions, whatever fits), and Keysat handles the buy page, payment via BTCPay, and a signed license for each buyer.</p>
<p>How you use that license inside your software is up to you: a one-time purchase to unlock the whole app, a free + paid split with specific paid features, a tip-jar style supporter badge: all legitimate. The licensing layer is a primitive, not a script.</p>
<p>These docs cover both ends:</p> <p>These docs cover both ends:</p>
@@ -68,14 +67,19 @@
<h4>Integrate the SDK &rarr;</h4> <h4>Integrate the SDK &rarr;</h4>
<p>Add the SDK to your app, embed your public key, verify a license at startup. About five lines of code.</p> <p>Add the SDK to your app, embed your public key, verify a license at startup. About five lines of code.</p>
</a> </a>
<a class="next-card" href="agent.html">
<span class="eyebrow">Agent / automation</span>
<h4>Agent integration &rarr;</h4>
<p>OpenAPI spec, scoped API keys, webhooks. Build bots that issue comp licenses, react to events, or automate support flows.</p>
</a>
</div> </div>
<h2 id="architecture">Architecture</h2> <h2 id="architecture">Architecture</h2>
<p>Keysat is the licensing layer sitting on top of your existing payments stack. Three boxes:</p> <p>Keysat is the licensing layer sitting on top of your existing payments stack. Three boxes:</p>
<ul> <ul>
<li><strong>BTCPay Server</strong> &mdash; takes the payment. On-chain Bitcoin or Lightning, settling to your wallet. Lives on your Start9.</li> <li><strong>BTCPay Server</strong>: takes the payment. On-chain Bitcoin or Lightning, settling to your wallet. Lives on your Start9.</li>
<li><strong>Keysat</strong> &mdash; your private licensing service. Holds the Ed25519 signing key. Hosts the public purchase URLs at <code>/buy/&lt;product&gt;</code>. Listens for BTCPay payment webhooks and issues a signed license on each settlement. Lives on your Start9.</li> <li><strong>Keysat</strong>: your private licensing service. Holds the Ed25519 signing key. Hosts the public purchase URLs at <code>/buy/&lt;product&gt;</code>. Listens for BTCPay payment webhooks and issues a signed license on each settlement. Lives on your Start9.</li>
<li><strong>Your software</strong> &mdash; the thing you sell. Ships with the Keysat <em>public</em> key embedded at compile time. On startup it reads the user&rsquo;s license and verifies the signature offline. No network call.</li> <li><strong>Your software</strong>: the thing you sell. Ships with the Keysat <em>public</em> key embedded at compile time. On startup it reads the user&rsquo;s license and verifies the signature offline. No network call.</li>
</ul> </ul>
<p>The key word is <em>offline</em>. Once a license is issued, your software does not need to phone home to verify it. The verification is a pure function of the license bytes and the public key. This is the same model used by signed JWTs, except wrapped in a small fixed-width format that&rsquo;s comfortable to print on a receipt.</p> <p>The key word is <em>offline</em>. Once a license is issued, your software does not need to phone home to verify it. The verification is a pure function of the license bytes and the public key. This is the same model used by signed JWTs, except wrapped in a small fixed-width format that&rsquo;s comfortable to print on a receipt.</p>
@@ -87,7 +91,7 @@
<h2 id="products-policies">Products &amp; policies</h2> <h2 id="products-policies">Products &amp; policies</h2>
<p>You declare two things in Keysat: products and policies.</p> <p>You declare two things in Keysat: products and policies.</p>
<p>A <strong>product</strong> is the thing you sell &mdash; "Bitcoin Ticker Pro", "Aurora Plugin", whatever. It has a slug, a display name, a description, and a price in sats.</p> <p>A <strong>product</strong> is the thing you sell: "Bitcoin Ticker Pro", "Aurora Plugin", whatever. It has a slug, a display name, a description, and a price (sats / USD / EUR). Each product also carries an <strong>entitlements catalog</strong>: the typed list of feature slugs your software cares about, plus their display names and descriptions. Policies pick entitlements from this catalog.</p>
<p>A <strong>policy</strong> is a license template attached to a product. It specifies:</p> <p>A <strong>policy</strong> is a license template attached to a product. It specifies:</p>
<table class="t"> <table class="t">
@@ -97,42 +101,92 @@
<tr><td><code>grace_seconds</code></td><td>Extra time after expiry before the verifier rejects.</td></tr> <tr><td><code>grace_seconds</code></td><td>Extra time after expiry before the verifier rejects.</td></tr>
<tr><td><code>max_machines</code></td><td>Seat cap. <code>0</code> means unlimited.</td></tr> <tr><td><code>max_machines</code></td><td>Seat cap. <code>0</code> means unlimited.</td></tr>
<tr><td><code>is_trial</code></td><td>Sets a <code>TRIAL</code> bit so your app can show a "trial" banner.</td></tr> <tr><td><code>is_trial</code></td><td>Sets a <code>TRIAL</code> bit so your app can show a "trial" banner.</td></tr>
<tr><td><code>entitlements</code></td><td>Free-form list of feature flags baked into the signed key (e.g. <code>core</code>, <code>sync</code>, <code>export</code>).</td></tr> <tr><td><code>is_recurring</code> + <code>renewal_period_days</code></td><td>Auto-renew on a cycle (weekly / monthly / annual / custom). The daemon mints a fresh invoice + signed license per cycle.</td></tr>
<tr><td><code>entitlements</code></td><td>Subset of the product&rsquo;s catalog this policy grants. Baked into the signed license.</td></tr>
<tr><td><code>metadata.marketing_bullets</code></td><td>Operator-authored ✓ items rendered on the buy-page tier card. Pure marketing copy. Not enforced.</td></tr>
<tr><td><code>metadata.hidden_entitlements</code></td><td>Slugs the license still grants but the buy-page card hides; useful when a higher tier uses "Everything in X, plus:" copy and doesn&rsquo;t want to repeat implied entitlements.</td></tr>
</tbody> </tbody>
</table> </table>
<p>Each product has one policy slugged <code>default</code> &mdash; that&rsquo;s the one consumed by the public purchase URL. You can attach additional named policies for manual issuance: a longer-duration "Lifetime" policy you hand out at conferences, a richer-entitlement "Pro" policy for upsells, etc.</p> <p>A product can have <strong>one policy or many</strong>. Multi-tier ladders (think Basic / Pro / Max) are first-class: when a product has two or more public policies, the buy page renders a tier picker and the buyer chooses before paying. The displayed tier is selected from a <code>?policy=&lt;slug&gt;</code> URL hint, then the <code>highlighted</code> ("most popular") policy if any, then the cheapest. Tier ordering on the picker is operator-controlled via drag-and-drop in the admin UI (or <code>tier_rank</code> in the API).</p>
<p>You can also attach <strong>private policies</strong> for manual issuance, e.g. a longer-duration "Lifetime" comp for conferences, a richer-entitlement "Internal" tier for support cases. Private policies don&rsquo;t appear on the buy page; the admin API issues them directly.</p>
<h2 id="merchant-profiles">Merchant profiles</h2>
<p>By default a Keysat instance sells for one business. On first boot it creates a single <strong>default merchant profile</strong> from your operator name, and every product you create attaches to it. If you only ever run one business, you can ignore profiles entirely: everything works against the default.</p>
<p>On the <strong>Pro</strong> and <strong>Patron</strong> tiers you can run more than one business from the same instance. A <strong>merchant profile</strong> is one business identity, and it owns:</p>
<ul>
<li><strong>Branding</strong>: display name, brand color, and the support URL / email shown on that business&rsquo;s buy pages.</li>
<li><strong>Payment accounts</strong>: its own BTCPay and Zaprite connections (one, the other, or both). Payments for that business settle to that business&rsquo;s wallet, never a shared one.</li>
<li><strong>A post-purchase redirect</strong>: where buyers land after paying (with <code>{invoice_id}</code> substituted), or the Keysat receipt page if you leave it blank.</li>
<li><strong>Products</strong>: each product attaches to exactly one profile. Buyers see that profile&rsquo;s brand on the buy page, and their payment routes to that profile&rsquo;s providers.</li>
</ul>
<p>So one operator, on one Start9, can sell Recaps under the Recaps brand (settling to the Recaps wallet) and Aurora under the Aurora brand (settling to the Aurora wallet) side by side, with separate checkout branding and separate books. Keysat is still not a shared SaaS: two independent sellers each run their own box. What profiles add is multiple businesses under one operator.</p>
<table class="t">
<thead><tr><th>Concept</th><th>What it is</th></tr></thead>
<tbody>
<tr><td>Default profile</td><td>Auto-created on first boot, exactly one per instance. A product with no explicit profile resolves to it.</td></tr>
<tr><td>Payment provider</td><td>One configured BTCPay or Zaprite account, attached to a profile. A profile can have more than one.</td></tr>
<tr><td>Rail</td><td>A buyer-facing payment method: Lightning, on-chain, or card. BTCPay serves Lightning and on-chain; Zaprite adds card.</td></tr>
<tr><td>Rail preference</td><td>A tie-breaker for when a profile has two providers that both serve the same rail: it sets which one wins.</td></tr>
</tbody>
</table>
<p>Manage all of this in the admin UI under <strong>Merchant profiles</strong>: create a profile, set its branding, connect BTCPay or Zaprite to it, mark one as the default, and attach products. Connecting a provider to a profile uses the same one-click authorize handshake as <a href="install.html#connect-btcpay">Connect BTCPay</a> in setup, just scoped to the profile you start it from.</p>
<div class="callout">
<i data-lucide="store"></i>
<p><strong>Running one business? Skip this.</strong> The default profile is created for you and every product uses it automatically. Merchant profiles only start to matter when you want a second brand or a second wallet on the same instance, which is a Pro and Patron capability.</p>
</div>
<h2 id="discounts">Discount codes</h2> <h2 id="discounts">Discount codes</h2>
<p>Three kinds:</p> <p>Four kinds:</p>
<table class="t"> <table class="t">
<thead><tr><th>Kind</th><th>What it does</th></tr></thead> <thead><tr><th>Kind</th><th>What it does</th></tr></thead>
<tbody> <tbody>
<tr><td><code>percent</code></td><td>Buyer appends <code>?code=FOUNDERS50</code> to the purchase URL; price drops by N%.</td></tr> <tr><td><code>percent</code></td><td>Buyer appends <code>?code=FOUNDERS50</code> to the purchase URL; price drops by N%.</td></tr>
<tr><td><code>fixed_sats</code></td><td>Like above, but a flat sat amount comes off.</td></tr> <tr><td><code>fixed_sats</code></td><td>Like above, but a flat amount comes off. Denominated in the code&rsquo;s <code>discount_currency</code> (sats / USD / EUR), so the same code can sit on top of multi-currency products.</td></tr>
<tr><td><code>set_price</code></td><td>Overrides the tier price with a flat number, regardless of base. Useful for "first 100 buyers at 25k sats" promos where you want the price to be a specific round number rather than a percentage off.</td></tr>
<tr><td><code>free_license</code></td><td>No payment at all. Buyer redeems the code via <code>POST /v1/redeem</code> and gets a signed license back.</td></tr> <tr><td><code>free_license</code></td><td>No payment at all. Buyer redeems the code via <code>POST /v1/redeem</code> and gets a signed license back.</td></tr>
</tbody> </tbody>
</table> </table>
<p>Codes can be capped at N uses, dated to expire, restricted to a single product, and tagged with a referrer label so you can see which campaign drove which sales in the audit log.</p> <p>Codes can be capped at N uses, dated to expire, restricted to one product (and optionally to a <strong>subset of policies</strong> on that product, e.g. "applies to Pro and Max but not Basic"), and tagged with a referrer label so you can see which campaign drove which sales in the audit log.</p>
<p>Codes can also be marked <strong>featured</strong>: a "launch special" mode. A featured code:</p>
<ul>
<li>Renders a diagonal "LAUNCH SPECIAL" ribbon + struck-through original price on the matching tier cards on the buy page.</li>
<li>Auto-applies for buyers who don&rsquo;t type any code, with the input pre-filled so they can see what&rsquo;s been applied.</li>
<li>Stops surfacing once it hits its <code>max_uses</code> cap or expires: the ribbon disappears and pricing reverts to standard automatically.</li>
</ul>
<p>Operator-typed codes always take precedence: a buyer who pastes a non-featured code in the form gets that code instead of the auto-applied featured one.</p>
<h2 id="revocation">Revocation strategy</h2> <h2 id="revocation">Revocation strategy</h2>
<p>This is the one piece of the architecture that requires a design decision from you.</p> <p>This is the one piece of the architecture that requires a design decision from you.</p>
<p>Because verification is offline, a license that was once issued continues to verify forever &mdash; even if you mark it as revoked in the admin UI. The verifier in your app doesn&rsquo;t know about your admin actions.</p> <p>Because verification is offline, a license that was once issued continues to verify forever, even if you mark it as revoked in the admin UI. The verifier in your app doesn&rsquo;t know about your admin actions.</p>
<p>You have three options:</p> <p>You have three options:</p>
<ul> <ul>
<li><strong>Don&rsquo;t support revocation at all.</strong> Many indie developers do this. Once a key is sold, it stays valid. Refunds are still possible &mdash; you send sats back via BTCPay; the key still works but the customer agreed to stop using it.</li> <li><strong>Don&rsquo;t support revocation at all.</strong> Many indie developers do this. Once a key is sold, it stays valid.</li>
<li><strong>Periodic online check.</strong> Your app fetches a small revocation list from your Keysat (or a CDN you point at it) once a week / month. Adds a "soft-online" requirement.</li> <li><strong>Periodic online check.</strong> Your app fetches a small revocation list from your Keysat (or a CDN you point at it) once a week / month. Adds a "soft-online" requirement.</li>
<li><strong>Short-lived licenses with renewal.</strong> Issue 30-day licenses; the app fetches a fresh signed token before expiry. v0.2 will ship recurring renewals as a first-class flow.</li> <li><strong>Short-lived licenses with renewal.</strong> Issue 30-day licenses; the app fetches a fresh signed token before expiry. Recurring renewals are first-class in v0.2: define a policy with <code>is_recurring=true</code> + <code>renewal_period_days</code> and Keysat handles the cycle (invoice → settle → re-sign → webhook).</li>
</ul> </ul>
<div class="callout"> <div class="callout">
<i data-lucide="key-round"></i> <i data-lucide="key-round"></i>
<p><strong>You decide the policy.</strong> Keysat doesn&rsquo;t force a particular revocation model. The default is no revocation &mdash; that&rsquo;s the simplest, sovereign-by-default choice. If you need stronger guarantees, layer them on with the patterns above.</p> <p><strong>You decide the policy.</strong> Keysat doesn&rsquo;t force a particular revocation model. The default is no revocation. That&rsquo;s the simplest, sovereign-by-default choice. If you need stronger guarantees, layer them on with the patterns above.</p>
</div> </div>
<h2 id="operator-tiers">Operator tiers</h2>
<p>Keysat itself ships under a tiered self-license. The daemon runs out of the box at the free <strong>Creator</strong> tier with caps that are generous for a solo developer; paid <strong>Pro</strong> and <strong>Patron</strong> tiers lift caps and unlock recurring billing + the Zaprite payment gateway. Caps are enforced by the daemon at create-time only; existing resources are always grandfathered if you downgrade.</p>
<p>As of this writing, Creator caps at <strong>5 products / 5 policies per product / 10 active discount codes</strong>, and Pro / Patron are unlimited. The exact tier list, prices, entitlements, and any active launch-special discount are operator-controlled on the master Keysat and may change. The canonical sources are:</p>
<ul>
<li>The live tier cards on <a href="https://keysat.xyz#tiers">keysat.xyz</a> (rendered dynamically from the master Keysat).</li>
<li>The <a href="pricing.html">pricing page</a> on these docs for the human-readable breakdown.</li>
<li><code>GET https://licensing.keysat.xyz/v1/products/keysat/policies</code> for the machine-readable shape (entitlements, marketing bullets, featured discount, etc.).</li>
<li>Your local daemon&rsquo;s <code>GET /v1/admin/tier</code> for current tier + caps + usage from inside the admin context.</li>
</ul>
<h2 id="next">Where to next</h2> <h2 id="next">Where to next</h2>
<div class="next-grid"> <div class="next-grid">
<a class="next-card" href="install.html"> <a class="next-card" href="install.html">
@@ -145,6 +199,11 @@
<h4>Integrate the SDK &rarr;</h4> <h4>Integrate the SDK &rarr;</h4>
<p>Embed your public key, add the SDK to your app, verify a license offline.</p> <p>Embed your public key, add the SDK to your app, verify a license offline.</p>
</a> </a>
<a class="next-card" href="agent.html">
<span class="eyebrow">Step 1 for agent builders</span>
<h4>Agent integration &rarr;</h4>
<p>Operate your Keysat instance programmatically. OpenAPI spec, scoped keys, webhooks, recipes.</p>
</a>
</div> </div>
</main> </main>
@@ -152,13 +211,16 @@
<div class="label">On this page</div> <div class="label">On this page</div>
<a href="#architecture">Architecture</a> <a href="#architecture">Architecture</a>
<a href="#products-policies">Products &amp; policies</a> <a href="#products-policies">Products &amp; policies</a>
<a href="#merchant-profiles">Merchant profiles</a>
<a href="#discounts">Discount codes</a> <a href="#discounts">Discount codes</a>
<a href="#revocation">Revocation strategy</a> <a href="#revocation">Revocation strategy</a>
<a href="#operator-tiers">Operator tiers</a>
<a href="#next">Where to next</a> <a href="#next">Where to next</a>
</aside> </aside>
</div> </div>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
<script src="docs.js"></script>
</body> </body>
</html> </html>
+57 -51
View File
@@ -3,22 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs Install &amp; setup</title> <title>Keysat Docs: Install &amp; setup</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css"> <link rel="stylesheet" href="docs.css">
</head> </head>
<body> <body>
<div class="topnav"> <div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a> <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> <span class="docs-tag">Docs</span>
<nav>
<a href="install.html" class="active">Install</a>
<a href="integrate.html">Integrate</a>
<a href="wire-format.html">Wire format</a>
<a href="operate.html">Operate</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div> </div>
<div class="layout"> <div class="layout">
@@ -28,19 +21,24 @@
<a href="index.html">Introduction</a> <a href="index.html">Introduction</a>
<a href="install.html" class="active">Install &amp; setup</a> <a href="install.html" class="active">Install &amp; setup</a>
<a href="integrate.html">Integrate the SDK</a> <a href="integrate.html">Integrate the SDK</a>
<a href="agent.html">Agent integration</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Concepts</div> <div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a> <a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a> <a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#merchant-profiles">Merchant profiles</a>
<a href="index.html#discounts">Discount codes</a> <a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a> <a href="index.html#revocation">Revocation strategy</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Reference</div> <div class="glabel">Reference</div>
<a href="wire-format.html">Wire format</a> <a href="wire-format.html">Wire format</a>
<a href="integrate.html#api">Admin API</a> </div>
<a href="integrate.html#sdks">SDKs</a> <div class="group">
<div class="glabel">Project</div>
<a href="pricing.html">Pricing</a>
<a href="license.html">License</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Operate</div> <div class="glabel">Operate</div>
@@ -62,7 +60,7 @@
<li>About 2 GB of free disk for Keysat itself; BTCPay&rsquo;s requirements are larger and depend on your Bitcoin node mode.</li> <li>About 2 GB of free disk for Keysat itself; BTCPay&rsquo;s requirements are larger and depend on your Bitcoin node mode.</li>
</ul> </ul>
<h2 id="install">Step 1 Install Keysat</h2> <h2 id="install">Step 1: Install Keysat</h2>
<p>Two ways. Either gets you to the same place.</p> <p>Two ways. Either gets you to the same place.</p>
<h3>Option A: from the Keysat marketplace (recommended)</h3> <h3>Option A: from the Keysat marketplace (recommended)</h3>
@@ -74,26 +72,41 @@
<h3>Option B: sideload</h3> <h3>Option B: sideload</h3>
<ol> <ol>
<li>Download <code>keysat_x86_64.s9pk</code> from the <a href="https://github.com/keysat-xyz/keysat/releases">GitHub releases page</a>.</li> <li>Download <code>keysat.s9pk</code> from the <a href="https://github.com/keysat-xyz/keysat/releases">GitHub releases page</a>.</li>
<li>In your StartOS dashboard, go to <strong>Sideload</strong> and drag the file in.</li> <li>In your StartOS dashboard, go to <strong>Sideload</strong> and drag the file in.</li>
<li>Click <strong>Install</strong>.</li> <li>Click <strong>Install</strong>.</li>
</ol> </ol>
<p>BTCPay Server is declared as a required dependency. If you don&rsquo;t have it installed yet, StartOS will prompt you to install it as part of the same flow.</p> <p>BTCPay Server is declared as a required dependency. If you don&rsquo;t have it installed yet, StartOS will prompt you to install it as part of the same flow.</p>
<h2 id="operator-name">Step 2 — Set your operator name</h2> <h2 id="admin-key">Step 2: Get your admin API key</h2>
<p>Open the Keysat service page in StartOS. Go to <strong>Actions &rarr; Set operator name</strong>. Pick a short label that identifies <em>you</em> as the seller &mdash; e.g. "aurora-software", "northpath", "my-name". This shows up on the public purchase pages and in the audit log.</p> <p>On Keysat&rsquo;s StartOS service page, go to <strong>Actions &rarr; Show credentials</strong>. This reveals the 64-hex-character admin API key that gates all <code>/v1/admin/*</code> endpoints, including the admin UI.</p>
<div class="callout warn">
<i data-lucide="alert-triangle"></i>
<p><strong>Treat this key like a password.</strong> Anyone with it can issue, revoke, or read every license you&rsquo;ve ever sold. Don&rsquo;t paste it into Slack. Don&rsquo;t check it into Git.</p>
</div>
<h2 id="admin-ui">Step 3: Open the admin UI</h2>
<p>Click the <strong>Launch UI</strong> button on Keysat&rsquo;s service page. (StartOS surfaces this for any service that defines a <code>type: 'ui'</code> interface.) Paste the admin key from the previous step into the sign-in form.</p>
<p>From here on, you work in the admin UI. The StartOS Actions tab is reserved for the few operations that must happen outside the web UI: showing credentials, setting the web UI password, and activating or checking the Keysat self-license.</p>
<h2 id="operator-name">Step 4: Set your operator name</h2>
<p>In the admin UI, go to <strong>Settings</strong>. Set your operator name there: a short label that identifies <em>you</em> as the seller, e.g. "aurora-software", "northpath", "my-name". This shows up on the public purchase pages and in the audit log.</p>
<p>This change is live-reloaded; you don&rsquo;t need to restart the service.</p> <p>This change is live-reloaded; you don&rsquo;t need to restart the service.</p>
<h2 id="connect-btcpay">Step 3 — Connect BTCPay</h2> <h2 id="connect-btcpay">Step 5: Connect BTCPay</h2>
<p>Make sure BTCPay Server is running and has at least one <strong>store</strong> with a configured <strong>payment method</strong> (on-chain wallet or Lightning node). Without a payment method, BTCPay will reject Keysat&rsquo;s invoice creation.</p> <p>Make sure BTCPay Server is running and has at least one <strong>store</strong> with a configured <strong>payment method</strong> (on-chain wallet or Lightning node). Without a payment method, BTCPay will reject Keysat&rsquo;s invoice creation.</p>
<p>In Keysat&rsquo;s service page, click <strong>Actions &rarr; Connect BTCPay</strong>. You&rsquo;ll be redirected to BTCPay&rsquo;s authorize page, where you grant Keysat the permissions it needs:</p> <p>In the admin web UI, go to <strong>Settings &rarr; Payment providers</strong> and click <strong>Connect BTCPay</strong> (agents can drive the same connect over the API with <code>POST /v1/admin/btcpay/connect</code>). You&rsquo;ll be redirected to BTCPay&rsquo;s authorize page, where you grant Keysat the permissions it needs:</p>
<ul> <ul>
<li><code>btcpay.store.canviewstoresettings</code></li>
<li><code>btcpay.store.canmodifystoresettings</code> (to register the settle webhook)</li>
<li><code>btcpay.store.canviewinvoices</code></li> <li><code>btcpay.store.canviewinvoices</code></li>
<li><code>btcpay.store.cancreateinvoice</code></li> <li><code>btcpay.store.cancreateinvoice</code></li>
<li><code>btcpay.store.canmodifywebhooks</code></li> <li><code>btcpay.store.canmodifyinvoices</code></li>
</ul> </ul>
<p>Once you confirm, BTCPay redirects back to Keysat with an API key and store id. Keysat:</p> <p>Once you confirm, BTCPay redirects back to Keysat with an API key and store id. Keysat:</p>
@@ -105,10 +118,12 @@
<div class="callout"> <div class="callout">
<i data-lucide="info"></i> <i data-lucide="info"></i>
<p><strong>Connect is idempotent.</strong> If you click it again later, Keysat detects the existing connection and returns success without re-authorizing. To force a re-authorize, run the <strong>Disconnect BTCPay</strong> action first.</p> <p><strong>Connect is idempotent.</strong> If you click it again later, Keysat detects the existing connection and returns success without re-authorizing. To force a re-authorize, disconnect first from <strong>Settings &rarr; Payment providers</strong> (or <code>POST /v1/admin/btcpay/disconnect</code>).</p>
</div> </div>
<p>Click <strong>Actions &rarr; Check BTCPay connection</strong> to verify the wiring. It should report:</p> <p>Automating setup? On a <strong>sandbox</strong> daemon you can connect a non-mainnet BTCPay over the API instead of clicking, using a scoped key carrying the <code>payment_providers:write</code> scope. See <a href="agent.html#connect-btcpay">Agent integration: Connect BTCPay programmatically</a>.</p>
<p>Back in <strong>Settings &rarr; Payment providers</strong> (or via <code>GET /v1/admin/btcpay/status</code>), verify the wiring. It should report:</p>
<pre class="code"><span class="c"># Expected output:</span> <pre class="code"><span class="c"># Expected output:</span>
status: <span class="s">connected</span> status: <span class="s">connected</span>
@@ -118,49 +133,39 @@ payment_methods: <span class="s">[BTC-OnChain, BTC-LightningNetwork]</span></pre
<p>If <code>payment_methods</code> is empty, head back to BTCPay and configure at least one before continuing.</p> <p>If <code>payment_methods</code> is empty, head back to BTCPay and configure at least one before continuing.</p>
<h2 id="admin-key">Step 4 — Get your admin API key</h2> <h2 id="first-product">Step 6: Define your first product</h2>
<p>Go to <strong>Actions &rarr; Show admin API key</strong>. This reveals the 64-hex-character key that gates all <code>/v1/admin/*</code> endpoints, including the admin UI.</p>
<div class="callout warn">
<i data-lucide="alert-triangle"></i>
<p><strong>Treat this key like a password.</strong> Anyone with it can issue, revoke, or read every license you&rsquo;ve ever sold. Don&rsquo;t paste it into Slack. Don&rsquo;t check it into Git.</p>
</div>
<h2 id="admin-ui">Step 5 — Open the admin UI</h2>
<p>Click the <strong>Launch UI</strong> button on Keysat&rsquo;s service page. (StartOS surfaces this for any service that defines a <code>type: 'ui'</code> interface.) Paste the admin key from the previous step into the sign-in form.</p>
<p>From here on, you mostly work in the admin UI. The StartOS Actions tab is reserved for setup-only operations (operator name, BTCPay connect/disconnect/check, show admin key).</p>
<h2 id="first-product">Step 6 — Define your first product</h2>
<p>In the admin UI, go to <strong>Products &rarr; Create a new product</strong> and fill in:</p> <p>In the admin UI, go to <strong>Products &rarr; Create a new product</strong> and fill in:</p>
<ul> <ul>
<li><strong>Slug</strong> &mdash; lowercase, hyphens, will appear in the public URL. e.g. <code>bitcoin-ticker-pro</code>.</li> <li><strong>Slug</strong>: lowercase, hyphens, will appear in the public URL. e.g. <code>bitcoin-ticker-pro</code>.</li>
<li><strong>Display name</strong> &mdash; shown on the buyer&rsquo;s purchase page and on receipts.</li> <li><strong>Display name</strong>: shown on the buyer&rsquo;s purchase page and on receipts.</li>
<li><strong>Description</strong> &mdash; one or two sentences; rendered as plain text.</li> <li><strong>Description</strong>: one or two sentences; rendered as plain text.</li>
<li><strong>Price (sats)</strong> &mdash; an integer. e.g. <code>50000</code> for ~$30 USD at current rates.</li> <li><strong>Price</strong>: the currency picker accepts sats, USD, or EUR. For sats, enter an integer (e.g. <code>50000</code>). For USD/EUR, enter the amount in dollars/euros. Keysat converts to BTC at invoice creation and the buyer pays the locked-in BTC amount.</li>
</ul> </ul>
<p>The product is created with no policies attached. Next:</p> <p>The product is created with no policies attached. Next:</p>
<h2 id="first-policy">Step 7 Define a default policy</h2> <h2 id="first-policy">Step 7: Define one or more policies</h2>
<p>Go to <strong>Policies &rarr; Create a new policy</strong>. Pick the product, then:</p> <p>Go to <strong>Policies &rarr; Create a new policy</strong>. Pick the product, then fill in:</p>
<ul> <ul>
<li>Set <strong>slug</strong> to <code>default</code>. This is the policy consumed by the public purchase flow; other slugs are reserved for manual issuance.</li> <li><strong>Slug</strong>: lowercase id (e.g. <code>basic</code>, <code>pro</code>, <code>annual</code>). Not "special" in any way; the buy page renders a tier picker when a product has two or more public policies, with the initial tier chosen by <code>?policy=&lt;slug&gt;</code> in the URL, then by the policy you mark "most popular", then by cheapest.</li>
<li>Set <strong>duration_seconds</strong>. Common choices: <code>0</code> (perpetual), <code>31536000</code> (1 year), <code>2592000</code> (30 days for trials).</li> <li><strong>Duration</strong>. Common choices: perpetual, 30 days (trial), 1 year. Recurring subscriptions are a separate toggle on the same form. Flip "Recurring subscription" + set a renewal cadence and Keysat handles the cycle (invoice &rarr; settle &rarr; re-sign) automatically.</li>
<li>Set <strong>max_machines</strong>. Use <code>1</code> for single-seat licenses or <code>0</code> for unlimited.</li> <li><strong>Max devices</strong>. <code>1</code> for single-seat, <code>0</code> for unlimited.</li>
<li>Optionally add <strong>entitlements</strong> &mdash; comma-separated feature flags. These are baked into the signed key and your app reads them at verify time.</li> <li><strong>Entitlements</strong>: pick from the product's catalog (you set up the catalog when you created the product on the previous step). The picked entitlements are baked into the signed license and your app reads them at verify time. Optionally toggle the "hide on buy page" eye icon on any entitlement to drop it from the tier card without un-granting it. Useful for higher tiers that use "Everything in Basic, plus:" marketing copy.</li>
<li><strong>Marketing bullets</strong> (optional): operator-authored ✓ items rendered on the tier card alongside the entitlements. Pure marketing copy, not enforced.</li>
</ul> </ul>
<h2 id="purchase-url">Step 8 — Share your purchase URL</h2> <p>If you're selling a multi-tier product (e.g. Basic / Pro / Max), repeat this step for each tier. Drag the cards in the Policies grid to set the order shown to buyers.</p>
<h2 id="purchase-url">Step 8: Share your purchase URL</h2>
<p>Your public purchase URL is now live at:</p> <p>Your public purchase URL is now live at:</p>
<pre class="code">https://&lt;your-keysat-host&gt;/buy/&lt;product-slug&gt;</pre> <pre class="code">https://&lt;your-keysat-host&gt;/buy/&lt;product-slug&gt;</pre>
<p>Buyers hit it, see your product, click "Pay", and BTCPay&rsquo;s checkout takes over. On payment confirmation, Keysat receives a webhook from BTCPay, signs a license, and emails it to the buyer (if they entered an email) and shows it on the receipt page.</p> <p>Buyers hit it, see your product, click "Pay", and BTCPay&rsquo;s checkout takes over. On payment confirmation, Keysat receives a webhook from BTCPay, signs a license, and shows it on the receipt page.</p>
<p>Test it end-to-end by creating a free-license discount code and redeeming it &mdash; the same code path runs, just without the payment leg.</p> <p>Test it end-to-end by creating a free-license discount code and redeeming it: the same code path runs, just without the payment leg.</p>
<h2 id="next">What&rsquo;s next</h2> <h2 id="next">What&rsquo;s next</h2>
<div class="next-grid"> <div class="next-grid">
@@ -181,10 +186,10 @@ payment_methods: <span class="s">[BTC-OnChain, BTC-LightningNetwork]</span></pre
<div class="label">On this page</div> <div class="label">On this page</div>
<a href="#prereq">Prerequisites</a> <a href="#prereq">Prerequisites</a>
<a href="#install">1. Install Keysat</a> <a href="#install">1. Install Keysat</a>
<a href="#operator-name">2. Set operator name</a> <a href="#admin-key">2. Get admin key</a>
<a href="#connect-btcpay">3. Connect BTCPay</a> <a href="#admin-ui">3. Open the admin UI</a>
<a href="#admin-key">4. Get admin key</a> <a href="#operator-name">4. Set operator name</a>
<a href="#admin-ui">5. Open the admin UI</a> <a href="#connect-btcpay">5. Connect BTCPay</a>
<a href="#first-product">6. First product</a> <a href="#first-product">6. First product</a>
<a href="#first-policy">7. Default policy</a> <a href="#first-policy">7. Default policy</a>
<a href="#purchase-url">8. Purchase URL</a> <a href="#purchase-url">8. Purchase URL</a>
@@ -193,5 +198,6 @@ payment_methods: <span class="s">[BTC-OnChain, BTC-LightningNetwork]</span></pre
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
<script src="docs.js"></script>
</body> </body>
</html> </html>
+107 -87
View File
@@ -3,22 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs Integrate the SDK</title> <title>Keysat Docs: Integrate the SDK</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css"> <link rel="stylesheet" href="docs.css">
</head> </head>
<body> <body>
<div class="topnav"> <div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a> <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> <span class="docs-tag">Docs</span>
<nav>
<a href="install.html">Install</a>
<a href="integrate.html" class="active">Integrate</a>
<a href="wire-format.html">Wire format</a>
<a href="operate.html">Operate</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div> </div>
<div class="layout"> <div class="layout">
@@ -28,19 +21,24 @@
<a href="index.html">Introduction</a> <a href="index.html">Introduction</a>
<a href="install.html">Install &amp; setup</a> <a href="install.html">Install &amp; setup</a>
<a href="integrate.html" class="active">Integrate the SDK</a> <a href="integrate.html" class="active">Integrate the SDK</a>
<a href="agent.html">Agent integration</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Concepts</div> <div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a> <a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a> <a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#merchant-profiles">Merchant profiles</a>
<a href="index.html#discounts">Discount codes</a> <a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a> <a href="index.html#revocation">Revocation strategy</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Reference</div> <div class="glabel">Reference</div>
<a href="wire-format.html">Wire format</a> <a href="wire-format.html">Wire format</a>
<a href="integrate.html#api">Admin API</a> </div>
<a href="integrate.html#sdks">SDKs</a> <div class="group">
<div class="glabel">Project</div>
<a href="pricing.html">Pricing</a>
<a href="license.html">License</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Operate</div> <div class="glabel">Operate</div>
@@ -53,23 +51,24 @@
<main class="prose"> <main class="prose">
<div class="crumb">Get started · Integrate the SDK</div> <div class="crumb">Get started · Integrate the SDK</div>
<h1>Integrate the SDK.</h1> <h1>Integrate the SDK.</h1>
<p class="lead">Wire Keysat licenses into your software in under an afternoon. The verifier is pure-function, offline, and ships in five lines. What you do with the result refuse to start without a license, unlock specific features, just show a "supporter" badge is your call. The SDK is the primitive; the business model is yours.</p> <p class="lead">Wire Keysat licenses into your software in under an afternoon. The verifier is pure-function, offline, and ships in five lines. What you do with the result (refuse to start without a license, unlock specific features, just show a "supporter" badge) is your call. The SDK is the primitive; the business model is yours.</p>
<h2 id="prereq">Prerequisites</h2> <h2 id="prereq">Prerequisites</h2>
<p>Before you start, you should have:</p> <p>Before you start, you should have:</p>
<ul> <ul>
<li>A Keysat installation running on your Start9 &mdash; see <a href="install.html">Install &amp; setup</a>.</li> <li>A Keysat installation running on your Start9; see <a href="install.html">Install &amp; setup</a>.</li>
<li>BTCPay Server connected to Keysat &mdash; ditto.</li> <li>BTCPay Server connected to Keysat; ditto.</li>
<li>At least one product defined in the admin UI.</li> <li>At least one product defined in the admin UI.</li>
</ul> </ul>
<h2 id="sdks">Pick an SDK</h2> <h2 id="sdks">Pick an SDK</h2>
<p>Three official SDKs ship today. They are wire-compatible &mdash; a license issued by your Keysat verifies identically in any of them.</p> <p>Four official SDKs ship today. They are wire-compatible. A license issued by your Keysat verifies identically in any of them. Cross-check fixtures in the daemon repo prove each SDK accepts the same bytes the daemon mints.</p>
<div class="lang-tabs" role="tablist"> <div class="lang-tabs" role="tablist">
<button class="active" data-lang="ts">TypeScript</button> <button class="active" data-lang="ts">TypeScript</button>
<button data-lang="rs">Rust</button> <button data-lang="rs">Rust</button>
<button data-lang="py">Python</button> <button data-lang="py">Python</button>
<button data-lang="go">Go</button>
</div> </div>
<pre class="code lang-pane" data-lang="ts"><span class="c"># npm</span> <pre class="code lang-pane" data-lang="ts"><span class="c"># npm</span>
@@ -79,16 +78,20 @@ npm install @keysat/licensing-client
pnpm add @keysat/licensing-client</pre> pnpm add @keysat/licensing-client</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c"># Cargo.toml</span> <pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c"># Cargo.toml</span>
[dependencies] [dependencies]
licensing-client = <span class="s">"0.1"</span></pre> keysat-licensing-client = <span class="s">"0.3"</span></pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># pip</span> <pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># pip</span>
pip install keysat-licensing-client pip install keysat-licensing-client
<span class="c"># or with poetry</span> <span class="c"># or with poetry</span>
poetry add keysat-licensing-client</pre> poetry add keysat-licensing-client</pre>
<pre class="code lang-pane" data-lang="go" style="display:none"><span class="c">// go.mod</span>
go get github.com/keysat-xyz/keysat-client-go
<span class="c">// stdlib only: no third-party Go dependencies</span></pre>
<p>If your language isn&rsquo;t covered, see <a href="wire-format.html">Wire format</a>. The format is small and porting takes about an afternoon.</p> <p>If your language isn&rsquo;t covered, see <a href="wire-format.html">Wire format</a>. The format is small and porting takes about an afternoon.</p>
<h2 id="embed">Step 1 Embed your public key</h2> <h2 id="embed">Step 1: Embed your public key</h2>
<p>In the admin UI, open <strong>Overview</strong> and copy the issuer public key from the "Embed your public key" card. (Or fetch it from <code>GET /v1/issuer/public-key</code>.) Paste it into your application&rsquo;s source code as a compile-time constant.</p> <p>In the admin UI, open <strong>Overview</strong> and copy the issuer public key from the "Embed your public key" card. (Or fetch it from <code>GET /v1/issuer/public-key</code>.) Paste it into your application&rsquo;s source code as a compile-time constant.</p>
<pre class="code lang-pane" data-lang="ts"><span class="k">const</span> <span class="f">ISSUER_PEM</span> = <span class="s">`-----BEGIN PUBLIC KEY----- <pre class="code lang-pane" data-lang="ts"><span class="k">const</span> <span class="f">ISSUER_PEM</span> = <span class="s">`-----BEGIN PUBLIC KEY-----
@@ -106,8 +109,8 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
<p><strong>Embed it. Don&rsquo;t fetch it.</strong> The whole point of offline verification is that your software can&rsquo;t be tricked by a network-level attacker. If you fetch the public key at runtime, you&rsquo;re back to trusting a server.</p> <p><strong>Embed it. Don&rsquo;t fetch it.</strong> The whole point of offline verification is that your software can&rsquo;t be tricked by a network-level attacker. If you fetch the public key at runtime, you&rsquo;re back to trusting a server.</p>
</div> </div>
<h2 id="verify">Step 2 Verify a license at startup</h2> <h2 id="verify">Step 2: Verify a license at startup</h2>
<p>Read the user&rsquo;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.</p> <p>Read the user&rsquo;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 <code>X-License-Key</code>) or the session, then verify it the same way.</p>
<pre class="code lang-pane" data-lang="ts"><span class="k">import</span> { <span class="f">Verifier</span>, <span class="f">PublicKey</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>; <pre class="code lang-pane" data-lang="ts"><span class="k">import</span> { <span class="f">Verifier</span>, <span class="f">PublicKey</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>;
@@ -115,120 +118,135 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
<span class="f">PublicKey</span>.<span class="f">fromPem</span>(ISSUER_PEM) <span class="f">PublicKey</span>.<span class="f">fromPem</span>(ISSUER_PEM)
); );
<span class="k">const</span> result = verifier.<span class="f">verify</span>(licenseKeyFromUser); <span class="c">// verify() returns the verified license, or THROWS if the key is missing,
// malformed, forged, or signed by someone else. Catch it (see step 3).</span>
<span class="k">const</span> license = verifier.<span class="f">verify</span>(licenseKeyFromUser);
<span class="c">// Now decide what to do with the result, based on YOUR business model. <span class="c">// What you do next is up to YOUR business model. The verified payload
// One-time purchase to use the app at all? Refuse to start unless valid. // carries the entitlements baked in at issue time.</span>
// Free + paid features? Check entitlements per feature. app.licensed = <span class="k">true</span>;
// Supporter badge only? Just render differently when valid.</span> app.entitlements = license.payload.entitlements; <span class="c">// string[]</span></pre>
<span class="k">if</span> (result.valid) { <pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">use</span> keysat_licensing_client::{<span class="f">Verifier</span>, <span class="f">PublicKeyPem</span>};
app.licensed = <span class="k">true</span>;
app.entitlements = result.entitlements;
}</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">use</span> licensing_client::{<span class="f">Verifier</span>, <span class="f">PublicKeyPem</span>};
<span class="k">let</span> pk = <span class="f">PublicKeyPem</span>::from_str(ISSUER_PEM)<span class="p">?</span>; <span class="k">let</span> pk = <span class="f">PublicKeyPem</span>::from_str(ISSUER_PEM)<span class="p">?</span>;
<span class="k">let</span> verifier = <span class="f">Verifier</span>::new(pk); <span class="k">let</span> verifier = <span class="f">Verifier</span>::new(pk);
<span class="k">let</span> result = verifier.verify(&amp;license_key)<span class="p">?</span>;
<span class="c">// What you do next is up to your business model.</span> <span class="c">// verify() returns Ok(VerifyOk) or Err on a bad key (see step 3).</span>
<span class="k">if</span> result.valid { <span class="k">let</span> license = verifier.verify(&amp;license_key)<span class="p">?</span>;
app.licensed = <span class="k">true</span>;
app.entitlements = result.entitlements; app.licensed = <span class="k">true</span>;
}</pre> app.entitlements = license.payload.entitlements;</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> Verifier, PublicKey <pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> Verifier, PublicKey
verifier = <span class="f">Verifier</span>(<span class="f">PublicKey</span>.<span class="f">from_pem</span>(ISSUER_PEM)) verifier = <span class="f">Verifier</span>(<span class="f">PublicKey</span>.<span class="f">from_pem</span>(ISSUER_PEM))
result = verifier.<span class="f">verify</span>(license_key_from_user)
<span class="c"># What you do with the result is your choice.</span> <span class="c"># verify() returns the verified license, or RAISES on a bad key (see step 3).</span>
<span class="k">if</span> result.valid: license = verifier.<span class="f">verify</span>(license_key_from_user)
app.licensed = <span class="k">True</span>
app.entitlements = result.entitlements</pre>
<p>The verifier returns a result object with the following fields:</p> app.licensed = <span class="k">True</span>
app.entitlements = license.payload.entitlements</pre>
<p>On success, <code>verify()</code> returns a <code>VerifyOk</code> result. There is no <code>valid</code> boolean: an invalid key throws (TS / Python) or returns <code>Err</code> (Rust). See step 3. Field names are camelCase in TS/JS and snake_case in Rust/Python.</p>
<table class="t"> <table class="t">
<thead><tr><th>Field</th><th>Type</th><th>Meaning</th></tr></thead> <thead><tr><th>Field</th><th>Type</th><th>Meaning</th></tr></thead>
<tbody> <tbody>
<tr><td><code>valid</code></td><td><code>bool</code></td><td>Signature checked, expiry not exceeded.</td></tr> <tr><td><code>productId</code></td><td><code>string</code></td><td>UUID of the product this license was issued for.</td></tr>
<tr><td><code>product_id</code></td><td><code>string</code></td><td>The product slug this license was issued for.</td></tr> <tr><td><code>licenseId</code></td><td><code>string</code></td><td>UUID of the license; useful for support tickets.</td></tr>
<tr><td><code>policy_slug</code></td><td><code>string</code></td><td>Which policy was active at issue time.</td></tr> <tr><td><code>payload.entitlements</code></td><td><code>string[]</code></td><td>Feature slugs baked into the signed payload.</td></tr>
<tr><td><code>license_id</code></td><td><code>string</code></td><td>UUID of the license; useful for support tickets.</td></tr> <tr><td><code>payload.issuedAt</code></td><td><code>number</code></td><td>Unix seconds at issue time.</td></tr>
<tr><td><code>issued_at</code></td><td><code>Date</code></td><td>UTC timestamp.</td></tr> <tr><td><code>payload.expiresAt</code></td><td><code>number</code></td><td>Unix seconds; <code>0</code> for perpetual.</td></tr>
<tr><td><code>expires_at</code></td><td><code>Date | null</code></td><td><code>null</code> for perpetual.</td></tr> <tr><td><code>payload.isTrial</code></td><td><code>bool</code></td><td>Set by the policy at issue time.</td></tr>
<tr><td><code>is_trial</code></td><td><code>bool</code></td><td>Set by the policy at issue time.</td></tr> <tr><td><code>payload.isFingerprintBound</code></td><td><code>bool</code></td><td>True if the key is bound to one machine.</td></tr>
<tr><td><code>seats</code></td><td><code>int</code></td><td>Max machines (0 = unlimited).</td></tr>
<tr><td><code>entitlements</code></td><td><code>Set&lt;string&gt;</code></td><td>Feature flags baked into the signed payload.</td></tr>
</tbody> </tbody>
</table> </table>
<h2 id="errors">Step 3 — Handle errors gracefully</h2> <div class="callout">
<i data-lucide="info"></i>
<p><strong><code>verify()</code> checks the signature and format, not expiry or revocation.</strong> A perpetual license never expires; to reject expired keys offline, compare the payload&rsquo;s <code>expiresAt</code> to now. Every SDK ships an <code>isExpiredAt</code>/<code>is_expired_at</code> helper for this; TS and Rust also offer a one-call <code>verifyWithTime(key, nowUnixSeconds)</code>. Live status (revoked, suspended, seats in use, the policy slug) isn&rsquo;t in the offline payload; get it from the online <a href="#renewals">validate</a> path below.</p>
</div>
<h2 id="errors">Step 3: Handle errors gracefully</h2>
<p>Verification can fail for benign reasons (the user hasn&rsquo;t pasted a license yet) or hostile ones (someone tampered with a license file). Distinguish them in your UX:</p> <p>Verification can fail for benign reasons (the user hasn&rsquo;t pasted a license yet) or hostile ones (someone tampered with a license file). Distinguish them in your UX:</p>
<pre class="code lang-pane" data-lang="ts"><span class="k">try</span> { <pre class="code lang-pane" data-lang="ts"><span class="k">import</span> { <span class="f">LicensingError</span> } <span class="k">from</span> <span class="s">'@keysat/licensing-client'</span>;
<span class="k">const</span> result = verifier.<span class="f">verify</span>(licenseKey);
<span class="k">if</span> (result.valid) <span class="f">grantAccess</span>(result); <span class="k">try</span> {
<span class="k">else</span> <span class="f">showRenewalPrompt</span>(result.expires_at); <span class="k">const</span> license = verifier.<span class="f">verify</span>(licenseKey); <span class="c">// throws if not valid</span>
<span class="f">grantAccess</span>(license);
} <span class="k">catch</span> (e) { } <span class="k">catch</span> (e) {
<span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">SignatureError</span>) <span class="f">showTamperWarning</span>(); <span class="c">// Every failure is a LicensingError with a machine-readable .code:
<span class="k">else</span> <span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">FormatError</span>) <span class="f">showInputError</span>(); // 'bad_signature' (tampered / forged), 'bad_format' or 'bad_encoding'
// (garbled input), 'bad_version', 'expired' (only from verifyWithTime).</span>
<span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">LicensingError</span> &amp;&amp; e.code === <span class="s">'bad_signature'</span>) <span class="f">showTamperWarning</span>();
<span class="k">else</span> <span class="k">if</span> (e <span class="k">instanceof</span> <span class="f">LicensingError</span>) <span class="f">showInputError</span>();
<span class="k">else</span> <span class="f">showGenericError</span>(e); <span class="k">else</span> <span class="f">showGenericError</span>(e);
}</pre> }</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">match</span> verifier.verify(&amp;license_key) { <pre class="code lang-pane" data-lang="rs" style="display:none"><span class="k">use</span> keysat_licensing_client::<span class="f">Error</span>;
<span class="k">Ok</span>(r) <span class="k">if</span> r.valid =&gt; grant_access(&amp;r),
<span class="k">Ok</span>(r) =&gt; show_renewal_prompt(r.expires_at), <span class="k">match</span> verifier.verify(&amp;license_key) {
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::SignatureError) =&gt; show_tamper_warning(), <span class="k">Ok</span>(license) =&gt; grant_access(&amp;license),
<span class="k">Err</span>(licensing_client::<span class="f">Error</span>::FormatError(_)) =&gt; show_input_error(), <span class="k">Err</span>(<span class="f">Error</span>::BadSignature) =&gt; show_tamper_warning(),
<span class="k">Err</span>(<span class="f">Error</span>::BadFormat(_) | <span class="f">Error</span>::BadEncoding(_)) =&gt; show_input_error(),
<span class="k">Err</span>(e) =&gt; show_generic_error(e), <span class="k">Err</span>(e) =&gt; show_generic_error(e),
}</pre> }</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> SignatureError, FormatError <pre class="code lang-pane" data-lang="py" style="display:none"><span class="k">from</span> keysat_licensing_client <span class="k">import</span> LicensingError
<span class="k">try</span>: <span class="k">try</span>:
result = verifier.<span class="f">verify</span>(license_key) license = verifier.<span class="f">verify</span>(license_key) <span class="c"># raises if not valid</span>
<span class="k">if</span> result.valid: grant_access(result) grant_access(license)
<span class="k">else</span>: show_renewal_prompt(result.expires_at) <span class="k">except</span> LicensingError <span class="k">as</span> e:
<span class="k">except</span> SignatureError: <span class="k">if</span> e.kind == <span class="s">"bad_signature"</span>:
show_tamper_warning() show_tamper_warning()
<span class="k">except</span> FormatError: <span class="k">elif</span> e.kind.startswith(<span class="s">"bad_"</span>):
show_input_error()</pre> show_input_error()
<span class="k">else</span>:
show_generic_error(e)</pre>
<h2 id="renewals">Renewals &amp; revocation</h2> <h2 id="renewals">Renewals &amp; revocation</h2>
<p>Keysat licenses are signed at issue time and do not phone home. If a license is revoked in the admin UI, the existing key continues to verify in your app &mdash; that&rsquo;s the trade-off for offline.</p> <p>Keysat licenses are signed at issue time and do not phone home. If a license is revoked in the admin UI, the existing key continues to verify in your app. That&rsquo;s the trade-off for offline.</p>
<p>If you need revocation, ship a thin <em>online</em> check that runs on a cadence (e.g. once a week) against your Keysat&rsquo;s revocation feed:</p> <p>If you need revocation, ship a thin <em>online</em> check that re-validates the key on a cadence (e.g. once a week) against your Keysat&rsquo;s <code>POST /v1/validate</code>. A revoked license returns <code>ok: false</code> with <code>reason: "revoked"</code>:</p>
<pre class="code lang-pane" data-lang="ts"><span class="c">// Optional. Run on a cadence, ignore network errors.</span> <pre class="code lang-pane" data-lang="ts"><span class="c">// Optional. Run on a cadence, ignore network errors.</span>
<span class="k">async function</span> <span class="f">checkRevocation</span>(licenseId: string) { <span class="k">async function</span> <span class="f">checkRevocation</span>(licenseKey: string) {
<span class="k">const</span> r = <span class="k">await</span> fetch(<span class="s">`https://your-keysat.example/v1/licenses/${licenseId}/status`</span>); <span class="k">const</span> r = <span class="k">await</span> fetch(<span class="s">'https://your-keysat.example/v1/validate'</span>, {
method: <span class="s">'POST'</span>,
headers: { <span class="s">'Content-Type'</span>: <span class="s">'application/json'</span> },
body: JSON.<span class="f">stringify</span>({ key: licenseKey }),
});
<span class="k">if</span> (r.ok) { <span class="k">if</span> (r.ok) {
<span class="k">const</span> j = <span class="k">await</span> r.json(); <span class="k">const</span> j = <span class="k">await</span> r.json();
<span class="k">if</span> (j.status === <span class="s">'revoked'</span>) <span class="f">disableApp</span>(); <span class="k">if</span> (!j.ok &amp;&amp; j.reason === <span class="s">'revoked'</span>) <span class="f">disableApp</span>();
} }
}</pre> }</pre>
<pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c">// Optional. Run on a cadence, ignore network errors.</span> <pre class="code lang-pane" data-lang="rs" style="display:none"><span class="c">// Optional. Run on a cadence, ignore network errors.</span>
<span class="k">async fn</span> check_revocation(license_id: &amp;<span class="k">str</span>) { <span class="k">async fn</span> check_revocation(license_key: &amp;<span class="k">str</span>) {
<span class="k">if let</span> <span class="k">Ok</span>(r) = reqwest::get(format!( <span class="k">let</span> body = serde_json::json!({ <span class="s">"key"</span>: license_key });
<span class="s">"https://your-keysat.example/v1/licenses/{}/status"</span>, <span class="k">if let</span> <span class="k">Ok</span>(r) = reqwest::<span class="f">Client</span>::new()
license_id .post(<span class="s">"https://your-keysat.example/v1/validate"</span>)
)).<span class="k">await</span> { .json(&amp;body)
<span class="k">if let</span> <span class="k">Ok</span>(j) = r.json::&lt;Status&gt;().<span class="k">await</span> { .send()
<span class="k">if</span> j.status == <span class="s">"revoked"</span> { disable_app(); } .<span class="k">await</span>
{
<span class="k">if let</span> <span class="k">Ok</span>(j) = r.json::&lt;ValidateResp&gt;().<span class="k">await</span> {
<span class="k">if</span> !j.ok &amp;&amp; j.reason.as_deref() == <span class="k">Some</span>(<span class="s">"revoked"</span>) { disable_app(); }
} }
} }
}</pre> }</pre>
<pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># Optional. Run on a cadence, ignore network errors.</span> <pre class="code lang-pane" data-lang="py" style="display:none"><span class="c"># Optional. Run on a cadence, ignore network errors.</span>
<span class="k">def</span> <span class="f">check_revocation</span>(license_id): <span class="k">def</span> <span class="f">check_revocation</span>(license_key):
<span class="k">try</span>: <span class="k">try</span>:
r = requests.get(<span class="s">f"https://your-keysat.example/v1/licenses/{license_id}/status"</span>, timeout=<span class="n">5</span>) r = requests.post(<span class="s">"https://your-keysat.example/v1/validate"</span>, json={<span class="s">"key"</span>: license_key}, timeout=<span class="n">5</span>)
<span class="k">if</span> r.json()[<span class="s">"status"</span>] == <span class="s">"revoked"</span>: j = r.json()
<span class="k">if</span> <span class="k">not</span> j[<span class="s">"ok"</span>] <span class="k">and</span> j.get(<span class="s">"reason"</span>) == <span class="s">"revoked"</span>:
disable_app() disable_app()
<span class="k">except</span> Exception: <span class="k">except</span> Exception:
<span class="k">pass</span></pre> <span class="k">pass</span></pre>
<div class="callout"> <div class="callout">
<i data-lucide="key-round"></i> <i data-lucide="key-round"></i>
<p><strong>You decide the policy.</strong> Many indie developers ship no revocation at all. Once a key is sold, it stays valid &mdash; refunds happen offline via BTCPay. That&rsquo;s perfectly reasonable.</p> <p><strong>You decide the policy.</strong> Many indie developers ship no revocation at all. Once a key is sold, it stays valid. That&rsquo;s perfectly reasonable.</p>
</div> </div>
<h2 id="api">Admin API</h2> <h2 id="api">Admin API</h2>
@@ -241,7 +259,8 @@ result = verifier.<span class="f">verify</span>(license_key_from_user)
<tr><td><code>POST</code></td><td><code>/v1/admin/products</code></td><td>Create a product.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/products</code></td><td>Create a product.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/policies</code></td><td>Create a policy.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/policies</code></td><td>Create a policy.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/discount-codes</code></td><td>Create a discount or comp code.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/discount-codes</code></td><td>Create a discount or comp code.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses/search</code></td><td>Find licenses by email, npub, or invoice.</td></tr> <tr><td><code>GET</code></td><td><code>/v1/admin/licenses</code></td><td>List a product&rsquo;s licenses; requires <code>?product_id=&lt;uuid&gt;</code>.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses/search</code></td><td>Search licenses by <code>buyer_email</code>, <code>nostr_npub</code>, or <code>invoice_id</code>.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/licenses/&lt;id&gt;/revoke</code></td><td>Revoke a license.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/licenses/&lt;id&gt;/revoke</code></td><td>Revoke a license.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/webhook-endpoints</code></td><td>Register an outbound webhook.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/webhook-endpoints</code></td><td>Register an outbound webhook.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/audit</code></td><td>Read audit log.</td></tr> <tr><td><code>GET</code></td><td><code>/v1/admin/audit</code></td><td>Read audit log.</td></tr>
@@ -280,5 +299,6 @@ result = verifier.<span class="f">verify</span>(license_key_from_user)
b.addEventListener('click', () => setLang(b.dataset.lang)); b.addEventListener('click', () => setLang(b.dataset.lang));
}); });
</script> </script>
<script src="docs.js"></script>
</body> </body>
</html> </html>
+140
View File
@@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs: License</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">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#merchant-profiles">Merchant profiles</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" class="active">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">Project &middot; License</div>
<h1>License.</h1>
<p class="lead">Keysat is a hybrid project. The daemon is <strong>source-available</strong> under a custom license; the four SDKs are <strong>open source (MIT)</strong>. The split is intentional: developers integrating Keysat into their own software should never have to think about license compatibility, while operators running the daemon agree to a few common-sense restrictions that keep the project sustainable.</p>
<h2 id="tldr">TL;DR</h2>
<table class="t">
<thead><tr><th>Component</th><th>License</th><th>Use freely?</th></tr></thead>
<tbody>
<tr>
<td>Keysat daemon<br><span class="muted" style="font-size:12.5px">(<code>keysat-xyz/keysat</code>)</span></td>
<td>Keysat Source-Available License 1.0<br><span class="muted" style="font-size:12.5px">(<code>LicenseRef-Keysat-1.0</code>)</span></td>
<td>Audit, run, modify ✓<br>Sell licenses to your own products ✓<br>Redistribute binaries ✗<br>Run as a hosted service for others ✗</td>
</tr>
<tr>
<td>SDKs<br><span class="muted" style="font-size:12.5px">(<code>keysat-client-{rust,ts,python,go}</code>)</span></td>
<td>MIT</td>
<td>Anything ✓<br>Including commercial use, redistribution, modification, sublicensing, private use.</td>
</tr>
<tr>
<td>Activate-license template<br><span class="muted" style="font-size:12.5px">(<code>keysat-activate-template</code>)</span></td>
<td>MIT</td>
<td>Anything ✓<br>Copy the buyer-side actions directly into your own StartOS package.</td>
</tr>
</tbody>
</table>
<h2 id="why">Why source-available for the daemon?</h2>
<p>Two reasons, both pragmatic:</p>
<ol>
<li><strong>The work has real cost.</strong> Building Keysat takes time. The source-available model lets the project be funded by operators on the Pro / Patron tiers who get value from a maintained, evolving daemon, without forcing every operator onto a paid tier.</li>
<li><strong>The "AWS-hosts-our-open-source" failure mode.</strong> Fully open-source self-hosted projects routinely get strip-mined by cloud providers who host them as a managed service and capture the revenue. The daemon license forbids this specific pattern. Everything else is permitted: running your own instance, modifying it, auditing the code, selling licenses for your own products through it.</li>
</ol>
<p>The SDKs are MIT because they sit inside <em>your</em> software. License compatibility there is critical and the MIT license is the modern default for libraries you embed.</p>
<h2 id="permitted">What you can do (daemon)</h2>
<ul>
<li><strong>Audit the source.</strong> Read every line; understand the cryptography, the storage, the API surface.</li>
<li><strong>Run an instance on infrastructure you control.</strong> A Start9 box at home, a VPS, a cloud instance: anywhere you deploy it.</li>
<li><strong>Modify it for your needs.</strong> Add features, change defaults, integrate it more deeply with your StartOS package. Modifications remain under the same license.</li>
<li><strong>Operate it as your private licensing service</strong> to issue signed license keys for software products <em>you</em> sell or distribute. This is the intended use case. Keysat exists for this.</li>
<li><strong>Maintain a public fork.</strong> Forks on GitHub are fine as long as they carry the license unchanged and don't enable any of the prohibited uses below.</li>
</ul>
<h2 id="forbidden">What you can't do without prior permission (daemon)</h2>
<ul>
<li><strong>Distribute compiled binaries to third parties.</strong> Including free of charge. The intent is that operators run Keysat themselves; they don't hand pre-built copies to others.</li>
<li><strong>Provide Keysat as a hosted / managed service to third parties.</strong> "Keysat-as-a-Service" run by a cloud provider for a fee, or by anyone other than the operator using it for their own products, is the one pattern explicitly forbidden. Your own customers receiving signed license keys from your instance are <em>not</em> a hosted service. That's the daemon's intended use case.</li>
<li><strong>Sell, sublicense, lease, or rent the daemon software itself.</strong> Distinct from selling licenses <em>through</em> the daemon, which is allowed.</li>
<li><strong>Remove copyright notices or this license text.</strong></li>
</ul>
<p>If you have a use case that crosses one of these lines (commercial redistribution, white-label deployment, a managed-service offering), email <a href="mailto:licensing@keysat.xyz">licensing@keysat.xyz</a>. The license isn't designed to be a wall; it's designed to make commercial expansion an explicit conversation rather than an implicit one.</p>
<h2 id="contributions">Contributions</h2>
<p>By submitting code, documentation, designs, or other contributions to the upstream daemon repo, you grant Keysat a perpetual, worldwide, non-exclusive, royalty-free license to use, modify, relicense, and redistribute your contribution under the same license (or any later version). You retain ownership of your contribution; this is a license-back, not an assignment. The full text is in <a href="https://github.com/keysat-xyz/keysat/blob/main/LICENSE">LICENSE Section 4</a>.</p>
<h2 id="full-text">Full license text</h2>
<p>The authoritative text lives at <a href="https://github.com/keysat-xyz/keysat/blob/main/LICENSE">github.com/keysat-xyz/keysat/blob/main/LICENSE</a>. This page is a plain-English summary; the LICENSE file is what governs in any conflict.</p>
<h2 id="sdks">SDK licenses</h2>
<p>Each SDK ships under the MIT License, included verbatim in the <code>LICENSE</code> file of each repo:</p>
<ul>
<li><a href="https://github.com/keysat-xyz/keysat-client-rust/blob/main/LICENSE">keysat-client-rust</a></li>
<li><a href="https://github.com/keysat-xyz/keysat-client-ts/blob/main/LICENSE">keysat-client-ts</a></li>
<li><a href="https://github.com/keysat-xyz/keysat-client-python/blob/main/LICENSE">keysat-client-python</a></li>
<li><a href="https://github.com/keysat-xyz/keysat-client-go/blob/main/LICENSE">keysat-client-go</a></li>
<li><a href="https://github.com/keysat-xyz/keysat-activate-template/blob/main/LICENSE">keysat-activate-template</a></li>
</ul>
<p>You can use these in any software: open-source, closed-source, commercial, free, anything. The only obligation MIT imposes is preserving the copyright notice when you redistribute the SDK source itself.</p>
<h2 id="contact">Commercial inquiries</h2>
<p>For commercial redistribution, resale, hosted-service rights, white-label deployment, or any other use not expressly granted by the source-available license: <a href="mailto:licensing@keysat.xyz">licensing@keysat.xyz</a>.</p>
</main>
<aside class="toc">
<div class="label">On this page</div>
<a href="#tldr">TL;DR</a>
<a href="#why">Why source-available</a>
<a href="#permitted">What you can do</a>
<a href="#forbidden">What you can't do</a>
<a href="#contributions">Contributions</a>
<a href="#full-text">Full license text</a>
<a href="#sdks">SDK licenses</a>
<a href="#contact">Commercial inquiries</a>
</aside>
</div>
<script src="docs.js"></script>
</body>
</html>
+24 -25
View File
@@ -3,22 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs Operate</title> <title>Keysat Docs: Operate</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css"> <link rel="stylesheet" href="docs.css">
</head> </head>
<body> <body>
<div class="topnav"> <div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a> <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> <span class="docs-tag">Docs</span>
<nav>
<a href="install.html">Install</a>
<a href="integrate.html">Integrate</a>
<a href="wire-format.html">Wire format</a>
<a href="operate.html" class="active">Operate</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div> </div>
<div class="layout"> <div class="layout">
@@ -28,19 +21,24 @@
<a href="index.html">Introduction</a> <a href="index.html">Introduction</a>
<a href="install.html">Install &amp; setup</a> <a href="install.html">Install &amp; setup</a>
<a href="integrate.html">Integrate the SDK</a> <a href="integrate.html">Integrate the SDK</a>
<a href="agent.html">Agent integration</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Concepts</div> <div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a> <a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a> <a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#merchant-profiles">Merchant profiles</a>
<a href="index.html#discounts">Discount codes</a> <a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a> <a href="index.html#revocation">Revocation strategy</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Reference</div> <div class="glabel">Reference</div>
<a href="wire-format.html">Wire format</a> <a href="wire-format.html">Wire format</a>
<a href="integrate.html#api">Admin API</a> </div>
<a href="integrate.html#sdks">SDKs</a> <div class="group">
<div class="glabel">Project</div>
<a href="pricing.html">Pricing</a>
<a href="license.html">License</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Operate</div> <div class="glabel">Operate</div>
@@ -56,14 +54,14 @@
<p class="lead">Backups, migration, recovery, and the things that go wrong. The "you didn&rsquo;t expect to need this page until you needed it" page.</p> <p class="lead">Backups, migration, recovery, and the things that go wrong. The "you didn&rsquo;t expect to need this page until you needed it" page.</p>
<h2 id="backups">Backups</h2> <h2 id="backups">Backups</h2>
<p>StartOS handles backups for you. By default, every service in your StartOS install is included in the same backup snapshot &mdash; you set the destination once (encrypted external drive, S3-compatible cloud, etc.) and StartOS schedules nightly snapshots.</p> <p>StartOS handles backups for you. By default, every service in your StartOS install is included in the same backup snapshot. You set the destination once (encrypted external drive, S3-compatible cloud, etc.) and StartOS schedules nightly snapshots.</p>
<p>The Keysat backup payload is intentionally tiny. It contains:</p> <p>The Keysat backup payload is intentionally tiny. It contains:</p>
<ul> <ul>
<li>The signing keypair (<code>/data/issuer-key.pem</code>).</li> <li>The SQLite database (<code>/data/keysat.db</code>), which holds the Ed25519 signing keypair in the <code>server_keys</code> table along with all products, policies, licenses, invoices, audit log, webhook subscribers, and operator settings.</li>
<li>The SQLite database (<code>/data/keysat.db</code>).</li>
<li>Migration history.</li> <li>Migration history.</li>
<li>The self-license file at <code>/data/keysat-license.txt</code>, if you've activated a paid Keysat tier.</li>
</ul> </ul>
<p>That&rsquo;s it. No log files (those rotate locally), no caches.</p> <p>That&rsquo;s it. No log files (those rotate locally), no caches.</p>
@@ -83,25 +81,25 @@
<li>On the new Start9, complete first-time setup with a fresh password. Don&rsquo;t install any services yet.</li> <li>On the new Start9, complete first-time setup with a fresh password. Don&rsquo;t install any services yet.</li>
<li>StartOS &rarr; Settings &rarr; Backups &rarr; Restore. Point at the same destination. Pick the most recent snapshot.</li> <li>StartOS &rarr; Settings &rarr; Backups &rarr; Restore. Point at the same destination. Pick the most recent snapshot.</li>
<li>StartOS restores all services in dependency order. Keysat will restore alongside BTCPay and Bitcoin Core. Bitcoin will need to re-sync if you&rsquo;re using Bitcoin Core (consider <a href="https://utxo.live">utxo.live</a> for assumeutxo to skip IBD).</li> <li>StartOS restores all services in dependency order. Keysat will restore alongside BTCPay and Bitcoin Core. Bitcoin will need to re-sync if you&rsquo;re using Bitcoin Core (consider <a href="https://utxo.live">utxo.live</a> for assumeutxo to skip IBD).</li>
<li>Once Keysat is running on the new box, your purchase URLs change &mdash; the LAN/Tor hostnames are different. Update any links you&rsquo;ve published.</li> <li>Once Keysat is running on the new box, your purchase URLs change: the LAN/Tor hostnames are different. Update any links you&rsquo;ve published.</li>
</ol> </ol>
<p>The signing keypair restores along with the database, so all previously-issued licenses verify identically against the same public key. You don&rsquo;t need to re-distribute the public key to your customers.</p> <p>The signing keypair restores along with the database, so all previously-issued licenses verify identically against the same public key. You don&rsquo;t need to re-distribute the public key to your customers.</p>
<h2 id="signing-key">Rotating the signing key</h2> <h2 id="signing-key">Rotating the signing key</h2>
<p>You generally don&rsquo;t want to rotate the signing key &mdash; doing so invalidates every license you&rsquo;ve ever issued. v0.1 doesn&rsquo;t support rotation; the key is generated once at first start and never changed.</p> <p>You generally don&rsquo;t want to rotate the signing key. Doing so invalidates every license you&rsquo;ve ever issued. There is no admin-UI affordance for rotation today; the key is generated once on first start (and persisted to the <code>server_keys</code> SQLite table) and stays there for the life of the instance.</p>
<p>If you absolutely need to rotate (e.g. you suspect the keypair has leaked off the box):</p> <p>If you absolutely need to rotate (e.g. you suspect the keypair has leaked off the box):</p>
<ol> <ol>
<li>Stop Keysat.</li> <li>Stop Keysat.</li>
<li>Move <code>/data/issuer-key.pem</code> aside.</li> <li>Drop the row in the <code>server_keys</code> table (or move the database aside entirely if you also want to start clean).</li>
<li>Restart Keysat &mdash; it will generate a fresh keypair on first run.</li> <li>Restart Keysat. It will generate a fresh keypair on first run.</li>
<li>Re-issue all active licenses to existing customers using the new key. The admin UI doesn&rsquo;t support bulk re-issuance yet; this is a manual SQL exercise.</li> <li>Re-issue all active licenses to existing customers using the new key. The admin UI doesn&rsquo;t support bulk re-issuance yet; this is a manual SQL + scripted-API exercise.</li>
<li>Push a software update that swaps the embedded public key.</li> <li>Push a software update that swaps the embedded public key in your downstream apps.</li>
</ol> </ol>
<p>The cleaner path, for v0.2 onward, will be to support a rolling rotation where both keys verify for a transition period.</p> <p>A future release may support rolling rotation (two keys verifying during a transition window). It's not on the v0.2 / v0.3 roadmap.</p>
<h2 id="troubleshooting">Troubleshooting</h2> <h2 id="troubleshooting">Troubleshooting</h2>
@@ -113,7 +111,7 @@
<p>BTCPay rejects the invoice request because the store has no configured wallet. Open BTCPay, find your store, and configure either an on-chain wallet or a Lightning node before retrying.</p> <p>BTCPay rejects the invoice request because the store has no configured wallet. Open BTCPay, find your store, and configure either an on-chain wallet or a Lightning node before retrying.</p>
<h3 id="t-webhook">Webhook deliveries failing</h3> <h3 id="t-webhook">Webhook deliveries failing</h3>
<p>Check the audit log in the admin UI &mdash; failed deliveries land there with the response status. Common causes:</p> <p>In the admin UI go to <strong>Webhooks</strong>. Failed deliveries past the 10-attempt retry budget land in the "Failed" filter (the DLQ), with the response status and an inline "Retry" button. The audit log is a secondary source. Common causes:</p>
<ul> <ul>
<li>Endpoint URL no longer reachable. Hit it manually with <code>curl</code> from your laptop to confirm.</li> <li>Endpoint URL no longer reachable. Hit it manually with <code>curl</code> from your laptop to confirm.</li>
<li>Endpoint rejecting on signature mismatch. Verify your endpoint is HMAC-validating against the secret you registered with.</li> <li>Endpoint rejecting on signature mismatch. Verify your endpoint is HMAC-validating against the secret you registered with.</li>
@@ -121,13 +119,13 @@
</ul> </ul>
<h3 id="t-db-locked">"database is locked" errors in logs</h3> <h3 id="t-db-locked">"database is locked" errors in logs</h3>
<p>Almost always a sign that two daemon instances are racing on the same SQLite file &mdash; usually because of a misconfigured supervisor. Confirm only one Keysat container is running. If you&rsquo;re seeing this on a fresh install with no customizations, file a bug report against the package version you&rsquo;re running.</p> <p>Almost always a sign that two daemon instances are racing on the same SQLite file, usually because of a misconfigured supervisor. Confirm only one Keysat container is running. If you&rsquo;re seeing this on a fresh install with no customizations, file a bug report against the package version you&rsquo;re running.</p>
<h3 id="t-time-skew">Licenses verifying as "expired" immediately after issue</h3> <h3 id="t-time-skew">Licenses verifying as "expired" immediately after issue</h3>
<p>Clock skew. Either the issuing host or the verifying host has the wrong time. Run NTP. StartOS keeps your Start9 in sync automatically; the issue is usually on the verifier side (e.g. an air-gapped buyer machine).</p> <p>Clock skew. Either the issuing host or the verifying host has the wrong time. Run NTP. StartOS keeps your Start9 in sync automatically; the issue is usually on the verifier side (e.g. an air-gapped buyer machine).</p>
<h2 id="logs">Reading the logs</h2> <h2 id="logs">Reading the logs</h2>
<p>Keysat logs to stdout, captured by StartOS. Tail them from the StartOS dashboard &mdash; Service page &rarr; Logs &rarr; Live tail.</p> <p>Keysat logs to stdout, captured by StartOS. Tail them from the StartOS dashboard: Service page &rarr; Logs &rarr; Live tail.</p>
<p>Useful log lines to grep for:</p> <p>Useful log lines to grep for:</p>
@@ -147,7 +145,7 @@
<ul> <ul>
<li>File an issue at <a href="https://github.com/keysat-xyz/keysat/issues">github.com/keysat-xyz/keysat/issues</a>. Include the package version (visible in the StartOS service page) and any relevant log lines.</li> <li>File an issue at <a href="https://github.com/keysat-xyz/keysat/issues">github.com/keysat-xyz/keysat/issues</a>. Include the package version (visible in the StartOS service page) and any relevant log lines.</li>
<li>Email <a href="mailto:licensing@keysat.xyz">licensing@keysat.xyz</a> for security-sensitive issues you don&rsquo;t want to disclose publicly.</li> <li>For security-sensitive issues you don&rsquo;t want to disclose publicly, or for paid Patron-tier direct support, email <a href="mailto:licensing@keysat.xyz">licensing@keysat.xyz</a>.</li>
</ul> </ul>
</main> </main>
@@ -164,5 +162,6 @@
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
<script src="docs.js"></script>
</body> </body>
</html> </html>
+136 -121
View File
@@ -3,10 +3,11 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs Pricing</title> <title>Keysat Docs: Pricing</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css"> <link rel="stylesheet" href="docs.css">
<style> <style>
/* Page-local: tier-card grid (only used here). */
.tier-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:18px; margin:24px 0; } .tier-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:18px; margin:24px 0; }
@media (max-width:760px) { .tier-grid { grid-template-columns:1fr; } } @media (max-width:760px) { .tier-grid { grid-template-columns:1fr; } }
.tier-card { .tier-card {
@@ -72,147 +73,161 @@
<body> <body>
<div class="topnav"> <div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a> <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> <span class="docs-tag">Docs</span>
<nav>
<a href="install.html">Install</a>
<a href="integrate.html">Integrate</a>
<a href="wire-format.html">Wire format</a>
<a href="operate.html">Operate</a>
<a href="pricing.html" aria-current="page">Pricing</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div> </div>
<div class="layout"> <div class="layout">
<aside class="side"> <aside class="side">
<a href="#overview">Overview</a> <div class="group">
<a href="#tiers">The three tiers</a> <div class="glabel">Get started</div>
<a href="#what-counts">What the caps count</a> <a href="index.html">Introduction</a>
<a href="#changing-tiers">Switching tiers</a> <a href="install.html">Install &amp; setup</a>
<a href="#unlicensed">Running unlicensed</a> <a href="integrate.html">Integrate the SDK</a>
<a href="#future">What's coming</a> <a href="agent.html">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#merchant-profiles">Merchant profiles</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" class="active">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> </aside>
<main class="main"> <main class="prose">
<div class="crumb">Project &middot; Pricing</div>
<h1 id="overview">Pricing.</h1>
<p class="lead">Keysat dogfoods its own licensing. The Keysat daemon is itself licensed by a Keysat instance running at <a href="https://licensing.keysat.xyz">licensing.keysat.xyz</a>. The same primitive operators use to gate features in their own software gates a few Keysat features behind paid tiers. The <strong>free tier is genuinely useful</strong>: most hobbyist operators will never need to upgrade.</p>
<h1 id="overview">Pricing</h1> <h2 id="tiers">The three tiers</h2>
<p class="lede">
Keysat dogfoods its own licensing — the Keysat daemon is itself licensed by a Keysat
instance running at <a href="https://licensing.keysat.xyz">licensing.keysat.xyz</a>.
The same primitive operators use to gate features in their own software gates a few
Keysat features behind paid tiers. The <strong>free tier is genuinely useful</strong>
most hobbyist operators will never need to upgrade.
</p>
<div id="tiers" class="tier-grid"> <div class="tier-grid">
<div class="tier-card">
<h3>Creator</h3>
<div class="price">Free<span class="unit">forever</span></div>
<div class="frequency">no payment required</div>
<ul>
<li>Up to 5 products</li>
<li>Up to 5 policies per product</li>
<li>Up to 10 active discount codes</li>
<li>BTCPay payments (Bitcoin / Lightning)</li>
<li>All four SDKs · full wire format</li>
<li>Webhooks, audit log, recovery, analytics opt-in</li>
<li>Self-host on Start9 (always)</li>
</ul>
<a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=creator">Get Creator</a>
</div>
<div class="tier-card featured">
<div class="badge">Most popular</div>
<h3>Pro</h3>
<div class="price">100,000<span class="unit">sats</span></div>
<div class="frequency">per year (recurring)</div>
<ul>
<li>Unlimited products / policies / codes</li>
<li>Recurring subscriptions: trials, grace, auto-renew</li>
<li>Zaprite payments (expanded payment options including card payment capabilities)</li>
<li>In-place tier upgrades (proration handled)</li>
<li>Everything in Creator</li>
</ul>
<a class="cta" href="https://licensing.keysat.xyz/buy/keysat?policy=pro">Upgrade to Pro</a>
</div>
<div class="tier-card">
<h3>Patron</h3>
<div class="price">250,000<span class="unit">sats</span></div>
<div class="frequency">one-time, perpetual</div>
<ul>
<li>Everything in Pro</li>
<li>Perpetual license: one-time, never renews</li>
<li>Direct one-on-one support</li>
<li>"Patron" badge in your admin UI</li>
<li>Listed on the Patrons page at keysat.xyz</li>
<li>Early access to release-candidate builds</li>
</ul>
<a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=patron">Become a Patron</a>
</div>
<div class="tier-card">
<h3>Creator</h3>
<div class="price">21,000<span class="unit">sats</span></div>
<div class="frequency">one-time, perpetual</div>
<ul>
<li>Up to 5 products</li>
<li>Up to 5 policies per product</li>
<li>Up to 5 active discount codes</li>
<li>BTCPay payments (Bitcoin / Lightning)</li>
<li>One-time purchases</li>
<li>Self-host on Start9 (always)</li>
<li>Distributed via free codes — ask</li>
</ul>
<a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=creator">Get Creator</a>
</div> </div>
<div class="tier-card featured"> <div class="note">
<div class="badge">Most popular</div> <strong>Prices shown are a snapshot.</strong> The canonical source is the live
<h3>Pro</h3> tier cards at <a href="https://keysat.xyz#tiers">keysat.xyz</a> (rendered
<div class="price">250,000<span class="unit">sats</span></div> dynamically from the master Keysat instance) and
<div class="frequency">per year</div> <a href="https://licensing.keysat.xyz/buy/keysat">licensing.keysat.xyz/buy/keysat</a>.
<ul> Launch-special discounts (when active) show on those pages with a "LAUNCH
<li>Unlimited products / policies / codes</li> SPECIAL" ribbon and the discount auto-applied; they're not represented here.
<li>Recurring subscriptions <em>(when shipped, v0.3)</em></li>
<li>Zaprite payments — accept BTC + cards <em>(when shipped, v0.3)</em></li>
<li>Multi-operator admin <em>(when shipped)</em></li>
<li>Everything in Creator</li>
</ul>
<a class="cta" href="https://licensing.keysat.xyz/buy/keysat?policy=pro">Upgrade to Pro</a>
</div> </div>
<div class="tier-card"> <div class="note">
<h3>Patron</h3> <strong>What's gated.</strong> Capacity caps (products / policies-per-product /
<div class="price">500,000<span class="unit">sats</span></div> active discount codes) are enforced at create-time on the Creator tier. Pro
<div class="frequency">per year</div> unlocks the <code>recurring_billing</code> entitlement (auto-renewing
<ul> subscriptions) and the <code>zaprite_payments</code> entitlement
<li>Same features as Pro</li> (expanded payment options including card payment capabilities).
<li>"Patron" badge in your admin UI</li> Patron differs from Pro in that it is a perpetual license (never
<li>Funds Keysat development</li> expires or renews), plus direct one-on-one support. It's not a feature
<li>Honest upsell — no fake feature gate</li> gate, it's a different ownership model.
</ul>
<a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=patron">Become a Patron</a>
</div> </div>
</div> <h2 id="what-counts">What the caps count</h2>
<p>
All caps fire at <strong>create-time only</strong>. Once you're under the cap,
you're never retroactively kicked off. A Creator-tier operator who currently
has 5 products keeps all 5 if you ever lower the caps in the future. The cap
just stops them from creating a 6th.
</p>
<ul>
<li><strong>Products</strong>: counts every product row in the database. Free up a slot by deleting products you no longer offer (the admin UI exposes delete; there's no "soft-disable" affordance for products).</li>
<li><strong>Policies per product</strong>: counts policies on a single product, regardless of public/active state.</li>
<li><strong>Active discount codes</strong>: counts only <code>active=true</code> codes. Disable old codes to free up slots without deleting them. Disabled codes don't function but stay in the audit trail.</li>
</ul>
<div class="note"> <h2 id="changing-tiers">Switching tiers</h2>
<strong>Note on what's gated.</strong> Today the only enforced gates are the <p>
capacity caps — number of products, policies, and active discount codes. Pro Buy a higher-tier license at <a href="https://licensing.keysat.xyz/buy/keysat">licensing.keysat.xyz/buy/keysat</a>,
will gate the new payment-provider features (recurring billing, Zaprite card then activate it via StartOS &rarr; Keysat &rarr; Actions &rarr; <em>Activate Keysat license</em>.
payments) when those ship in v0.3. Patron is functionally identical to Pro — The daemon picks up the new entitlements on next request. No restart needed.
its tier exists for operators who want to fund development beyond what Pro The persistent banner in your admin sidebar always shows your current tier
needs to stay in the green. and the next-tier CTA.
</div> </p>
<p>
<strong>Downgrading</strong>: drop your license file (or replace with a lower
tier). Existing rows stay; new ones are subject to the new caps. No data loss.
</p>
<h2 id="what-counts">What the caps count</h2> <h2 id="unlicensed">Running unlicensed</h2>
<p> <p>
All caps fire at <strong>create-time only</strong>. Once you're under the cap, Keysat works without any license at all. You'll see "Unlicensed" in the
you're never retroactively kicked off. A Creator-tier operator who currently sidebar and get the same caps as a Creator-tier operator
has 5 products keeps all 5 if you ever lower the caps in the future. The cap (5 products / 5 policies per product / 10 active discount codes). The
just stops them from creating a 6th. Creator tier is free either way; the self-license flow exists primarily so
</p> operators get a real "I bought it" experience for the paid tiers and so we
<ul> can offer the upgrade path to Pro. Hobbyists can run Keysat indefinitely
<li><strong>Products</strong>: counts every product row (active + inactive). Operators don't get to evade the cap by toggling old rows inactive.</li> without paying us a sat.
<li><strong>Policies per product</strong>: counts policies on a single product, regardless of public/active state.</li> </p>
<li><strong>Active discount codes</strong>: counts only <code>active=true</code> codes. Disable old codes to free up slots without deleting them — disabled codes don't function but stay in the audit trail.</li>
</ul>
<h2 id="changing-tiers">Switching tiers</h2>
<p>
Buy a higher-tier license at <a href="https://licensing.keysat.xyz/buy/keysat">licensing.keysat.xyz/buy/keysat</a>,
then activate it via StartOS → Keysat → Actions → <em>Activate Keysat license</em>.
The daemon picks up the new entitlements on next request — no restart needed.
The persistent banner in your admin sidebar always shows your current tier
and the next-tier CTA.
</p>
<p>
<strong>Downgrading</strong>: drop your license file (or replace with a lower
tier). Existing rows stay; new ones are subject to the new caps. No data loss.
</p>
<h2 id="unlicensed">Running unlicensed</h2>
<p>
Keysat works without any license at all — you'll see "Unlicensed" in the
sidebar and get the same caps as a Creator-tier operator (5/5/5). The
license exists primarily so operators get a real "I bought it" experience
and so we can offer the upgrade path to Pro. Hobbyists can run Keysat
indefinitely without paying us a sat.
</p>
<h2 id="future">What's coming</h2>
<p>
Two big v0.3 features will be Pro-only when they ship:
</p>
<ul>
<li><strong>Recurring subscriptions</strong> — auto-renewal, customer-managed cancellation.</li>
<li><strong>Zaprite payments</strong> — accept Bitcoin <em>and</em> credit cards. Massively expands your addressable buyers.</li>
</ul>
<p>
Both will gate on the <code>recurring_billing</code> and <code>card_payments</code> entitlements
respectively. Free-tier operators will see a "Pro feature" banner and a one-click
upgrade flow when they try to use these.
</p>
</main> </main>
</div> </div>
<script src="docs.js"></script>
</body> </body>
</html> </html>
+67 -77
View File
@@ -3,22 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs Wire format reference</title> <title>Keysat Docs: Wire format reference</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css"> <link rel="stylesheet" href="docs.css">
</head> </head>
<body> <body>
<div class="topnav"> <div class="topnav">
<a href="index.html" class="brand"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a> <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> <span class="docs-tag">Docs</span>
<nav>
<a href="install.html">Install</a>
<a href="integrate.html">Integrate</a>
<a href="wire-format.html" class="active">Wire format</a>
<a href="operate.html">Operate</a>
<a href="https://keysat.xyz">Marketing</a>
</nav>
</div> </div>
<div class="layout"> <div class="layout">
@@ -28,19 +21,24 @@
<a href="index.html">Introduction</a> <a href="index.html">Introduction</a>
<a href="install.html">Install &amp; setup</a> <a href="install.html">Install &amp; setup</a>
<a href="integrate.html">Integrate the SDK</a> <a href="integrate.html">Integrate the SDK</a>
<a href="agent.html">Agent integration</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Concepts</div> <div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a> <a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a> <a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#merchant-profiles">Merchant profiles</a>
<a href="index.html#discounts">Discount codes</a> <a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a> <a href="index.html#revocation">Revocation strategy</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Reference</div> <div class="glabel">Reference</div>
<a href="wire-format.html" class="active">Wire format</a> <a href="wire-format.html" class="active">Wire format</a>
<a href="integrate.html#api">Admin API</a> </div>
<a href="integrate.html#sdks">SDKs</a> <div class="group">
<div class="glabel">Project</div>
<a href="pricing.html">Pricing</a>
<a href="license.html">License</a>
</div> </div>
<div class="group"> <div class="group">
<div class="glabel">Operate</div> <div class="glabel">Operate</div>
@@ -56,68 +54,59 @@
<p class="lead">The bytes-over-the-wire spec for a Keysat license. Stable across SDKs and across language ports. About 90 lines of pseudocode to implement in a new language.</p> <p class="lead">The bytes-over-the-wire spec for a Keysat license. Stable across SDKs and across language ports. About 90 lines of pseudocode to implement in a new language.</p>
<h2 id="overview">Overview</h2> <h2 id="overview">Overview</h2>
<p>A Keysat license key looks like this on a receipt:</p> <p>A Keysat license key on a receipt looks like this:</p>
<pre class="code">KS-9F2A-7C41-XK22-6D8E-LM77-PQ91</pre> <pre class="code">LIC1-&lt;base32 payload&gt;-&lt;base32 signature&gt;</pre>
<p>Strip the <code>KS-</code> prefix and the dashes, and you have a Crockford base32-encoded blob. Base32-decode that blob, and you get the binary <em>license envelope</em>: a fixed-layout struct followed by an Ed25519 signature.</p> <p>Three parts, separated by single dashes:</p>
<ul>
<li><code>LIC1</code>: literal envelope tag. Future format revisions get a new tag (<code>LIC2</code> etc.). Parsers MUST reject unknown tags.</li>
<li><code>&lt;base32 payload&gt;</code>: the signed payload bytes, RFC 4648 base32 without padding (case-insensitive on decode). Variable length depending on payload version and number of entitlements.</li>
<li><code>&lt;base32 signature&gt;</code>: the 64-byte Ed25519 signature over the <em>raw payload bytes</em>, base32-encoded the same way.</li>
</ul>
<p>To verify: split on <code>-</code>, validate the tag is <code>LIC1</code>, base32-decode both chunks (case-fold to upper), parse the payload, and verify the signature bytes against the raw payload bytes using the issuer&rsquo;s Ed25519 public key.</p>
<h2 id="layout">Binary layout</h2> <h2 id="versions">Two payload versions</h2>
<p>All multi-byte integers are big-endian.</p> <p>Keysat ships two payload versions today. v2 is the current default that the daemon issues; v1 verifiers stay in the SDKs forever so legacy keys keep verifying.</p>
<h3>v1 (legacy, fixed 74 bytes)</h3>
<p>Issued by the very early daemon builds. No expiry, no entitlements. Perpetual only, fingerprint binding optional. Still accepted on parse so old customer keys don&rsquo;t break.</p>
<table class="t"> <table class="t">
<thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead> <thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
<tbody> <tbody>
<tr><td><code>0</code></td><td>4</td><td>Magic</td><td>ASCII <code>KSAT</code> (0x4B 0x53 0x41 0x54).</td></tr> <tr><td><code>0</code></td><td>1</td><td>version</td><td><code>0x01</code></td></tr>
<tr><td><code>4</code></td><td>1</td><td>Version</td><td>Currently <code>0x01</code>. Decoders MUST reject unknown versions.</td></tr> <tr><td><code>1</code></td><td>1</td><td>flags</td><td>Bit 0: fingerprint-bound. Other bits reserved.</td></tr>
<tr><td><code>5</code></td><td>1</td><td>Flags</td><td>Bit 0: <code>TRIAL</code>. Bit 1: <code>PERPETUAL</code>. Bits 2&ndash;7 reserved.</td></tr> <tr><td><code>2</code></td><td>16</td><td>product_id</td><td>UUID, big-endian bytes.</td></tr>
<tr><td><code>6</code></td><td>16</td><td>License ID</td><td>UUIDv4 binary form.</td></tr> <tr><td><code>18</code></td><td>16</td><td>license_id</td><td>UUID, big-endian bytes.</td></tr>
<tr><td><code>22</code></td><td>16</td><td>Issuer fingerprint</td><td>SHA-256 of the issuer public key, truncated to 16 bytes.</td></tr> <tr><td><code>34</code></td><td>8</td><td>issued_at</td><td>Unix seconds, u64 big-endian.</td></tr>
<tr><td><code>38</code></td><td>8</td><td>Issued-at</td><td>Unix seconds, signed.</td></tr> <tr><td><code>42</code></td><td>32</td><td>fingerprint_hash</td><td>SHA-256 of the machine fingerprint; all zeros if not bound.</td></tr>
<tr><td><code>46</code></td><td>8</td><td>Expires-at</td><td>Unix seconds, signed. <code>0</code> if <code>PERPETUAL</code> flag is set.</td></tr>
<tr><td><code>54</code></td><td>2</td><td>Seats</td><td>Max machines. <code>0</code> = unlimited.</td></tr>
<tr><td><code>56</code></td><td>2</td><td>Payload length</td><td>Length <code>L</code> of the variable-size payload that follows.</td></tr>
<tr><td><code>58</code></td><td><code>L</code></td><td>Payload</td><td>UTF-8 JSON: <code>{ "product": "...", "policy": "...", "entitlements": [...] }</code>.</td></tr>
<tr><td><code>58 + L</code></td><td>64</td><td>Signature</td><td>Ed25519 signature over bytes <code>0 .. (58 + L)</code>.</td></tr>
</tbody> </tbody>
</table> </table>
<h2 id="encoding">Crockford base32</h2> <h3>v2 (current default, variable length)</h3>
<p>Keysat uses <a href="https://www.crockford.com/base32.html">Crockford&rsquo;s base32 alphabet</a> (<code>0123456789ABCDEFGHJKMNPQRSTVWXYZ</code>) without checksum, without padding, and case-insensitive on decode.</p> <p>83-byte fixed head + variable-length entitlements table. v2 adds expiry, trial flag, and entitlements, all signed so offline verifiers can gate features without contacting the server (a stripped entitlement or pushed-back expiry would have to match a valid signature, which the attacker can&rsquo;t produce).</p>
<table class="t">
<p>The reason for Crockford over standard base32: human-friendly. <code>I</code>, <code>L</code>, <code>O</code>, <code>U</code> are excluded from the alphabet to avoid ambiguity when typing keys off a printed receipt.</p> <thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
<tbody>
<h2 id="grouping">Dash grouping &amp; prefix</h2> <tr><td><code>0</code></td><td>1</td><td>version</td><td><code>0x02</code></td></tr>
<p>For display, keys are upper-cased, then grouped into 4-character chunks separated by dashes, and prefixed with <code>KS-</code>:</p> <tr><td><code>1</code></td><td>1</td><td>flags</td><td>Bit 0: fingerprint-bound. Bit 1: trial (best-effort hint for clients).</td></tr>
<tr><td><code>2</code></td><td>16</td><td>product_id</td><td>UUID, big-endian bytes.</td></tr>
<pre class="code"><span class="c">// raw base32, length depends on payload size</span> <tr><td><code>18</code></td><td>16</td><td>license_id</td><td>UUID, big-endian bytes.</td></tr>
9F2A7C41XK226D8ELM77PQ91RR54VV01 <tr><td><code>34</code></td><td>8</td><td>issued_at</td><td>Unix seconds, u64 big-endian.</td></tr>
<tr><td><code>42</code></td><td>8</td><td>expires_at</td><td>Unix seconds, u64 big-endian. <code>0</code> means perpetual.</td></tr>
<span class="c">// grouped + prefixed for display</span> <tr><td><code>50</code></td><td>32</td><td>fingerprint_hash</td><td>SHA-256 of the machine fingerprint; all zeros if not bound.</td></tr>
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-RR54-VV01</pre> <tr><td><code>82</code></td><td>1</td><td>entitlements_count</td><td><code>N</code>, 0&ndash;255.</td></tr>
<tr><td><code>83..</code></td><td>variable</td><td>entitlements</td><td><code>N</code> entries, each <code>&lt;len: u8&gt;&lt;ascii bytes&gt;</code>. Each entitlement string is ≤255 bytes.</td></tr>
<p>Decoders MUST strip the <code>KS-</code> prefix (case-insensitive), strip whitespace and dashes, and case-fold to upper before base32-decoding.</p> </tbody>
</table>
<h2 id="signature">Signature</h2> <h2 id="signature">Signature</h2>
<p>The signature covers the entire envelope from offset <code>0</code> through the end of the payload &mdash; that is, all bytes <em>before</em> the 64-byte signature itself.</p> <p>The signature is computed over the <strong>raw payload bytes</strong>: the binary head plus any entitlements table, without the version tag, without base32 encoding, without dashes. The two base32 chunks in the wire format are encoded <em>independently</em>; concatenating them and base32-decoding the whole would be wrong.</p>
<p>Verify with the issuer&rsquo;s Ed25519 public key (PEM-encoded, SubjectPublicKeyInfo). The SDKs ship the public key bundled in your app at build time; they don&rsquo;t fetch it at runtime. (The whole point of offline verification is that a network-level attacker can&rsquo;t hand your software a different key.)</p>
<p>Verify with the issuer&rsquo;s Ed25519 public key. The fingerprint at offset 22 lets the verifier confirm that the key it has matches the key the license was signed with: SHA-256 the public key bytes, truncate to 16 bytes, compare. If it doesn&rsquo;t match, the verifier MUST reject before attempting signature check &mdash; this gives a clear "wrong issuer" error rather than a generic "bad signature".</p> <h2 id="encoding">Base32 alphabet</h2>
<p>Standard RFC 4648 base32 (alphabet <code>A&ndash;Z, 2&ndash;7</code>), no padding, case-insensitive on decode. The daemon emits uppercase. Decoders MUST strip whitespace and case-fold to upper before decoding.</p>
<h2 id="example">Worked example</h2> <p>Why not Crockford / hex / base58: standard base32 has wide library support, encodes 5 bytes per 8 characters (slightly tighter than hex), is case-insensitive for type-on-receipt scenarios, and avoids the I/O/0/1 ambiguity of base58.</p>
<p>Test vector for the Python SDK&rsquo;s cross-check tests (issuer fingerprint <code>0xfeed face cafe babe...</code>, single-seat perpetual license):</p>
<pre class="code"><span class="c"># Hex dump of the binary envelope</span>
00000000 4B 53 41 54 01 02 9F 2A 7C 41 XK 22 6D 8E LM 77 <span class="c">|KSAT...*|A.."m..w|</span>
00000010 PQ 91 RR 54 VV 01 FE ED FA CE CA FE BA BE 00 00 <span class="c">|...T....|........|</span>
00000020 00 00 00 00 65 4F 12 34 00 00 00 00 00 00 00 00 <span class="c">|....eO.4|........|</span>
00000030 00 01 00 24 7B 22 70 72 6F 64 75 63 74 22 3A 22 <span class="c">|...${"product":"|</span>
00000040 73 75 6E 64 69 61 6C 22 2C 22 70 6F 6C 69 63 79 <span class="c">|sundial","policy|</span>
00000050 22 3A 22 64 65 66 61 75 6C 74 22 7D ...sig... <span class="c">|":"default"}.....|</span>
<span class="c"># As displayed</span>
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-…</pre>
<p>The full vector lives in <code>licensing-client-python/tests/fixtures/canonical.json</code> and is what every official SDK is tested against.</p>
<h2 id="public-key">Issuer public key format</h2> <h2 id="public-key">Issuer public key format</h2>
<p>Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:</p> <p>Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:</p>
@@ -129,42 +118,42 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
<p>This is the same encoding that <code>openssl pkey -pubout</code> produces. Keysat exposes it at <code>GET /v1/issuer/public-key</code>:</p> <p>This is the same encoding that <code>openssl pkey -pubout</code> produces. Keysat exposes it at <code>GET /v1/issuer/public-key</code>:</p>
<pre class="code">{ <pre class="code">{
<span class="s">"public_key_pem"</span>: <span class="s">"-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"</span>, <span class="s">"key_algorithm"</span>: <span class="s">"ed25519"</span>,
<span class="s">"public_key_b64"</span>: <span class="s">"mz7q8r4t1v…h3k2pXq9wL"</span>, <span class="s">"key_format_version"</span>: <span class="n">2</span>,
<span class="s">"fingerprint_hex"</span>: <span class="s">"feed face cafe babe …"</span> <span class="s">"public_key_pem"</span>: <span class="s">"-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"</span>
}</pre> }</pre>
<p>The <code>public_key_pem</code> field is the one you embed in your app. Verification needs only the PEM; the SDKs parse it with <code>PublicKey.fromPem(...)</code>.</p>
<h2 id="porting">Porting to a new language</h2> <h2 id="porting">Porting to a new language</h2>
<p>The wire format is small enough to port in an afternoon. The order is:</p> <p>The wire format is small enough to port in an afternoon. The order is:</p>
<ol> <ol>
<li>Copy the test vectors from <a href="https://github.com/keysat-xyz/licensing-client-python/blob/main/tests/fixtures/canonical.json">licensing-client-python/tests/fixtures/canonical.json</a>.</li> <li>Pull the canonical cross-check vectors from the daemon repo at <a href="https://github.com/keysat-xyz/keysat/tree/main/licensing-service/tests/crosscheck"><code>licensing-service/tests/crosscheck/</code></a>. Vectors cover v1 legacy, v2 trial-with-entitlements, and v2 perpetual-unbound fixtures.</li>
<li>Implement Crockford base32 decode (~30 lines).</li> <li>Implement RFC 4648 base32 decode (most languages have this in stdlib).</li>
<li>Implement the binary unmarshal (~40 lines, mostly offset arithmetic).</li> <li>Implement the binary unmarshal for both v1 and v2 payloads (~80 lines total, mostly big-endian integer reads).</li>
<li>Wire it up to your language&rsquo;s Ed25519 verifier from a vetted crypto library.</li> <li>Wire it up to your language&rsquo;s Ed25519 verifier from a vetted crypto library (libsodium, ring, ed25519-dalek, the Node/Python stdlib, etc.).</li>
<li>Run the cross-check tests &mdash; if they pass, you&rsquo;re wire-compatible.</li> <li>Run the cross-check tests. If all three vector cases pass byte-for-byte, you&rsquo;re wire-compatible.</li>
</ol> </ol>
<p>See <a href="https://github.com/keysat-xyz/keysat/blob/main/PORTING_SDK_TO_NEW_LANGUAGES.md">PORTING_SDK_TO_NEW_LANGUAGES.md</a> in the repo for the full contributor guide.</p> <p>The four official SDKs (Rust, TypeScript, Python, Go) all sit on top of these same fixtures and the daemon&rsquo;s test suite asserts each implementation round-trips them identically before a release ships.</p>
<h2 id="versioning">Versioning policy</h2> <h2 id="versioning">Versioning policy</h2>
<p>The version byte at offset 4 is a hard gate. Decoders MUST reject any version they don&rsquo;t implement. We commit to:</p> <p>The version byte at payload offset <code>0</code> is a hard gate. Decoders MUST reject any version they don&rsquo;t implement (no graceful skip-over). We commit to:</p>
<ul> <ul>
<li>Never silently changing the v1 layout. Any change &rArr; new version byte.</li> <li>Never silently changing an existing layout. Any field-shape change ⇒ new version byte.</li>
<li>Maintaining v1 verifier support indefinitely &mdash; even if v2 ships, your existing customer keys stay verifiable.</li> <li>Maintaining v1 + v2 verifier support indefinitely. If v3 ever ships, your existing customer keys still verify against the daemon and the SDKs they shipped with.</li>
<li>Publishing test vectors for every new version under <code>tests/fixtures/</code> in the canonical SDK.</li> <li>The wire-envelope tag (<code>LIC1-…</code>) bumps only on a breaking envelope change. New payload versions live inside the same envelope tag as long as the split-on-dash structure stays the same.</li>
<li>Publishing test vectors for every payload version under <code>tests/crosscheck/</code> in the daemon repo. All five implementations (daemon, Rust SDK, TypeScript SDK, Python SDK, Go SDK) are required to round-trip the same vectors byte-for-byte before a release ships.</li>
</ul> </ul>
</main> </main>
<aside class="toc"> <aside class="toc">
<div class="label">On this page</div> <div class="label">On this page</div>
<a href="#overview">Overview</a> <a href="#overview">Overview</a>
<a href="#layout">Binary layout</a> <a href="#versions">Payload versions</a>
<a href="#encoding">Crockford base32</a>
<a href="#grouping">Dash grouping</a>
<a href="#signature">Signature</a> <a href="#signature">Signature</a>
<a href="#example">Worked example</a> <a href="#encoding">Base32 alphabet</a>
<a href="#public-key">Public key format</a> <a href="#public-key">Public key format</a>
<a href="#porting">Porting</a> <a href="#porting">Porting</a>
<a href="#versioning">Versioning policy</a> <a href="#versioning">Versioning policy</a>
@@ -173,5 +162,6 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
<script src="docs.js"></script>
</body> </body>
</html> </html>