Compare commits

..

10 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
9 changed files with 323 additions and 131 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.
+168 -32
View File
@@ -27,6 +27,7 @@
<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>
@@ -54,7 +55,7 @@
<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> <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> <h2 id="quick-start">Quick start</h2>
<pre><code># 1. Discover the API surface <pre class="code"># 1. Discover the API surface
curl https://your-keysat-host/v1/openapi.json curl https://your-keysat-host/v1/openapi.json
# 2. Generate a scoped API key (admin UI: Settings → API keys, or via curl) # 2. Generate a scoped API key (admin UI: Settings → API keys, or via curl)
@@ -64,13 +65,13 @@ curl -X POST https://your-keysat-host/v1/admin/api-keys \
-d '{"label":"Support bot","role":"support"}' -d '{"label":"Support bot","role":"support"}'
# Response includes `token: ks_...`. Save it. It's only shown once. # Response includes `token: ks_...`. Save it. It's only shown once.
# 3. Use the scoped key # 3. Use the scoped key (admin/licenses requires a product_id)
curl https://your-keysat-host/v1/admin/licenses?status=active \ curl "https://your-keysat-host/v1/admin/licenses?product_id=&lt;uuid&gt;" \
-H "Authorization: Bearer ks_..."</code></pre> -H "Authorization: Bearer ks_..."</pre>
<h2 id="auth">Authentication</h2> <h2 id="auth">Authentication</h2>
<p>All admin endpoints use HTTP Bearer auth:</p> <p>All admin endpoints use HTTP Bearer auth:</p>
<pre><code>Authorization: Bearer &lt;token&gt;</code></pre> <pre class="code">Authorization: Bearer &lt;token&gt;</pre>
<p>Two kinds of tokens are accepted.</p> <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>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>
@@ -84,10 +85,14 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
<tr><td><code>read-only</code></td><td>List / get every resource. Mutate nothing.</td></tr> <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>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>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> <tr><td><code>full-admin</code></td><td>Every scope. Equivalent to the master key for most endpoints.</td></tr>
</tbody> </tbody>
</table> </table>
<p>Endpoints that touch settings (operator name, payment provider connections, self-license activation, scoped API key management) always require the master admin key. A <code>full-admin</code> scoped key cannot, for example, generate another scoped key. That's a self-defeating elevation path.</p> <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> <h2 id="discovery">Discovering the API</h2>
<p>Two complementary discovery mechanisms.</p> <p>Two complementary discovery mechanisms.</p>
@@ -108,12 +113,12 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
<h2 id="envelope">Response envelope conventions</h2> <h2 id="envelope">Response envelope conventions</h2>
<p>Every error response uses the same JSON envelope:</p> <p>Every error response uses the same JSON envelope:</p>
<pre><code>{ <pre class="code">{
"ok": false, "ok": false,
"error": "tier_cap", "error": "tier_cap",
"message": "Your Creator tier allows up to 5 products. You're at 5...", "message": "Your Creator tier allows up to 5 products. You're at 5...",
"upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro" "upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro"
}</code></pre> }</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> <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> <h3>Error codes</h3>
@@ -151,61 +156,191 @@ curl https://your-keysat-host/v1/admin/licenses?status=active \
<h2 id="workflows">Common workflows</h2> <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> <h3>Issue a comp license</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses \ <pre class="code">curl -X POST $KS/v1/admin/licenses \
-H "Authorization: Bearer ks_..." \ -H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"product_slug": "recap", "product_slug": "recap",
"policy_slug": "pro", "policy_slug": "pro",
"buyer_email": "alice@example.com", "buyer_email": "alice@example.com",
"buyer_note": "Conference speaker comp" "note": "Conference speaker comp"
}'</code></pre> }'</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>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> <p><em>Scope required: <code>licenses:write</code> (any role except <code>read-only</code>).</em></p>
<h3>Revoke a license</h3> <h3>Revoke a license</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \ <pre class="code">curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-H "Authorization: Bearer ks_..." \ -H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"reason":"refund issued"}'</code></pre> -d '{"reason":"customer request"}'</pre>
<p>Idempotent. The next online validate from the buyer's app returns <code>reason: revoked</code>.</p> <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> <p><em>Scope required: <code>licenses:write</code>.</em></p>
<h3>Find a license by email</h3> <h3>Find a license by email</h3>
<pre><code>curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \ <pre class="code">curl "$KS/v1/admin/licenses/search?buyer_email=alice@example.com" \
-H "Authorization: Bearer ks_..."</code></pre> -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>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> <p><em>Scope required: <code>licenses:read</code>.</em></p>
<h3>Cancel a buyer's subscription</h3> <h3>Cancel a buyer's subscription</h3>
<pre><code># Look up the subscription id first (filter by license_id if you have it) <pre class="code"># Look up the subscription id first (filter by license_id if you have it)
curl "$KS/v1/admin/subscriptions?status=active" \ curl "$KS/v1/admin/subscriptions?status=active" \
-H "Authorization: Bearer ks_..." -H "Authorization: Bearer ks_..."
# Then cancel # Then cancel
curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \ curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
-H "Authorization: Bearer ks_..." \ -H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer requested"}'</code></pre> -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>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> <p><em>Scope required: <code>subscriptions:write</code>.</em></p>
<h3>Free a machine seat</h3> <h3>Free a machine seat</h3>
<pre><code>curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \ <pre class="code">curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
-H "Authorization: Bearer ks_..." \ -H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer moved devices"}'</code></pre> -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>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> <p><em>Scope required: <code>machines:write</code>.</em></p>
<h3>Programmatic tier change (comp upgrade)</h3> <h3>Programmatic tier change (comp upgrade)</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \ <pre class="code">curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
-H "Authorization: Bearer ks_..." \ -H "Authorization: Bearer ks_..." \
-d '{ -d '{
"target_policy_slug": "pro", "to_policy_slug": "pro",
"skip_payment": true,
"reason": "support resolution" "reason": "support resolution"
}'</code></pre> }'</pre>
<p>Always applies as comp (no invoice) from the admin path. Buyer-initiated paid upgrades go through <code>/v1/upgrade</code> (different endpoint, signed-license auth).</p> <p>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>Scope required: <code>licenses:write</code>.</em></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> <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> <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>
@@ -225,11 +360,11 @@ curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
</table> </table>
<p>Verify signatures:</p> <p>Verify signatures:</p>
<pre><code>import hmac, hashlib <pre class="code">import hmac, hashlib
def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool: def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest() expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)</code></pre> 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> <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> <h2 id="robust">Designing a robust agent</h2>
@@ -242,7 +377,7 @@ def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
<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> <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> <h3>Rate limits</h3>
<p>The admin endpoints have no per-IP rate limit today. Operators are trusted. The public endpoints (<code>/v1/validate</code>, <code>/v1/recover</code>) are rate-limited per client IP (10/min for <code>/recover</code>; <code>/validate</code> is unlimited but a reasonable agent calls it once per app boot + once per hour).</p> <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> <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> <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>
@@ -251,7 +386,7 @@ def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
<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> <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> <h2 id="recipe">Concrete recipe: "Comp a license to anyone who emails support@"</h2>
<pre><code>import os, requests, imaplib, email <pre class="code">import os, requests, imaplib, email
KS = os.environ["KEYSAT_URL"] KS = os.environ["KEYSAT_URL"]
TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key
@@ -264,27 +399,27 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
"product_slug": product_slug, "product_slug": product_slug,
"policy_slug": "default", "policy_slug": "default",
"buyer_email": buyer_email, "buyer_email": buyer_email,
"buyer_note": reason, "note": reason,
}, },
timeout=10, timeout=10,
) )
r.raise_for_status() r.raise_for_status()
return r.json()["license_key"] return r.json()["license_key"]
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key</code></pre> # 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> <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> <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> <p>Some operations are deliberately operator-only and not accessible to any scoped key, including <code>full-admin</code>:</p>
<ul> <ul>
<li>Generating / revoking scoped API keys (<code>/v1/admin/api-keys</code>)</li> <li>Generating / revoking scoped API keys (<code>/v1/admin/api-keys</code>)</li>
<li>Connecting / disconnecting payment providers</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>Setting the operator name</li>
<li>Activating the self-license (<code>/v1/admin/self-license</code>)</li> <li>Activating the self-license (<code>/v1/admin/self-license</code>)</li>
<li>Resetting the analytics install_uuid</li> <li>Resetting the analytics install_uuid</li>
<li>Changing the web UI password (StartOS Action only)</li> <li>Changing the web UI password (StartOS Action only)</li>
</ul> </ul>
<p>These all require the master <code>KEYSAT_ADMIN_API_KEY</code>. The reasoning: an agent that can rotate its own credentials, connect arbitrary payment processors, or change the operator identity is no longer bounded by the role it was given.</p> <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> <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> <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>
@@ -297,6 +432,7 @@ def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
<a href="#discovery">Discovering the API</a> <a href="#discovery">Discovering the API</a>
<a href="#envelope">Response envelope</a> <a href="#envelope">Response envelope</a>
<a href="#workflows">Common workflows</a> <a href="#workflows">Common workflows</a>
<a href="#worked-example">Worked example</a>
<a href="#webhooks">Webhooks</a> <a href="#webhooks">Webhooks</a>
<a href="#robust">Designing a robust agent</a> <a href="#robust">Designing a robust agent</a>
<a href="#recipe">Recipe: comp-license bot</a> <a href="#recipe">Recipe: comp-license bot</a>
+31 -1
View File
@@ -27,6 +27,7 @@
<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>
@@ -110,6 +111,34 @@
<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>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> <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>Four kinds:</p> <p>Four kinds:</p>
@@ -138,7 +167,7 @@
<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. 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. 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> <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>
@@ -182,6 +211,7 @@
<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="#operator-tiers">Operator tiers</a>
+31 -26
View File
@@ -27,6 +27,7 @@
<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>
@@ -71,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, 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>
@@ -102,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>
@@ -115,19 +133,6 @@ 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>
<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> <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>
@@ -158,7 +163,7 @@ payment_methods: <span class="s">[BTC-OnChain, BTC-LightningNetwork]</span></pre
<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: 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>
@@ -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>
+83 -66
View File
@@ -27,6 +27,7 @@
<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>
@@ -109,7 +110,7 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
</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>;
@@ -117,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>
<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> <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. 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. 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>
@@ -243,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</code></td><td>List / search licenses by buyer email or BTCPay invoice id. (Backend also supports npub search; the buyer-side npub capture flow is still in progress.)</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>
+1
View File
@@ -27,6 +27,7 @@
<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>
+1
View File
@@ -27,6 +27,7 @@
<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>
+1
View File
@@ -90,6 +90,7 @@
<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>
+5 -3
View File
@@ -27,6 +27,7 @@
<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>
@@ -117,10 +118,11 @@ 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>