Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.
migrations/0022_btcpay_state_profile.sql (new)
Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
the BTCPay OAuth state token can round-trip the operator's profile
pick between start_connect and the callback. Without this, multi-
profile operators couldn't authorize a SECOND BTCPay store onto a
non-default profile.
btcpay/config.rs
record_authorize_state takes merchant_profile_id; consume_authorize_state
now returns Option<String> so the callback knows which profile to
attach the new provider row to.
api/btcpay_authorize.rs (full rewrite)
start_connect accepts an optional merchant_profile_id (defaulting to
the default profile), refuses if that profile already has a BTCPay
provider attached (unique-index-friendly 409 message), and records
the profile id on the CSRF state token. The OAuth round-trip carries
the profile id back via the state token, not via a query param —
state-token-by-row is more robust than depending on BTCPay preserving
redirect-URL query params during the consent dance.
finish_connect (the callback's inner path):
- Pre-generates the payment_providers row id so it can be baked into
the BTCPay-side webhook callback URL.
- The webhook URL we register with BTCPay is now path-keyed:
/v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
isolated deliveries.
- INSERTs into payment_providers (kind='btcpay', api_key, base_url,
webhook_id, webhook_secret, store_id, attached to the chosen
profile) instead of upserting the singleton btcpay_config row.
- Populates the back-compat state.payment singleton ONLY when this
is the first provider on the default profile (so the few remaining
legacy state.payment_provider() callers still work without a
daemon restart).
disconnect accepts an optional provider_id; defaults to "the BTCPay
provider on the default profile" for back-compat with the existing
admin UI's single Disconnect button. Best-effort BTCPay-side webhook
+ API key revocation unchanged. DELETE FROM payment_providers WHERE
id = ? instead of clearing btcpay_config.
status + payment_methods report on the default-profile BTCPay row for
the legacy admin UI. Multi-profile operators will use the new
/v1/admin/merchant-profiles endpoints (part 4).
api/webhook.rs
Split into two entry points:
- handle_for_provider — the new path-keyed shape
(`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
via state.payment_provider_by_id, validates the payload against
THAT specific provider's secret, then runs the inner pipeline.
- handle — back-compat for the bare /v1/{kind}/webhook path. Routes
to whichever provider is on the default profile. Kept so any
in-flight pre-:52 webhook delivery or admin misconfiguration
doesn't silently drop on the floor.
Both share an extracted handle_inner that does the actual settle /
expire / refund processing.
api/mod.rs
Route registrations:
- Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
- Removes the legacy /v1/admin/payment-provider/activate route
(the shim function is gone).
Thank-you page provider-kind lookup ports from the deprecated
read_active_provider_preference to: invoice.payment_provider_id ->
payment_providers.kind -> ProviderKind. Falls back to the default
profile's first provider if the invoice predates migration 0021.
api/payment_provider.rs
Reduced to just the back-compat status endpoint. The activate
endpoint is removed entirely — there's no "active" preference to
flip in the merchant-profile model. Status returns the same
btcpay_configured / zaprite_configured / active shape the existing
admin UI consumes, plus a new providers[] array for callers that
want the full picture.
Build: cargo check passes. Only two warnings remaining — both
expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
branch in read_active_provider_preference that runs during the
pre-:52 upgrade window before migration 0020 has dropped the
settings row)
What's left for :52:
- New admin endpoints for merchant-profile + rail-preference CRUD
- Admin UI in web/index.html (biggest remaining chunk — Merchant
Profiles section + product picker + buy-page brand block +
rail picker)
- Tier-cap wire-up for unlimited_merchant_profiles
- Version bump + release notes + sandbox test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keysat
Self-hosted licensing server. Sell software on payment channels you control, verify licenses offline, keep the keys + customer list on your hardware. Runs on Start9.
keysat.xyz · docs.keysat.xyz · Releases
Quick start
Operator (install Keysat on your Start9): add registry.keysat.xyz to your StartOS marketplace and install. Sideload the .s9pk from GitHub releases if you prefer. See Install & setup for the full walkthrough.
Developer (verify a license in your software): four official SDKs ship today, all wire-compatible against the same cross-check fixtures in licensing-service/tests/crosscheck/.
| Language | Install |
|---|---|
| TypeScript | npm install @keysat/licensing-client |
| Rust | cargo add keysat-licensing-client |
| Python | pip install keysat-licensing-client |
| Go | go get github.com/keysat-xyz/keysat-client-go |
See Integrate the SDK for the five-line verifier pattern.
Operator agent / automation: the daemon exposes an OpenAPI 3.1 spec, scoped API keys with role-based access, and outbound webhooks. See Agent integration.
About this README. Keysat is a from-scratch service authored for StartOS — there is no upstream project to differ from. The canonical implementation is this package and the Rust daemon it wraps (
licensing-service/). Where this README would normally explain "differences from upstream," it instead documents the architecture directly. Anything that isn't documented here matches the source.
Table of Contents
- What Keysat is
- Image and Container Runtime
- Volume and Data Layout
- Installation and First-Run Flow
- Configuration Management
- Network Access and Interfaces
- Actions (StartOS UI)
- Backups and Restore
- Health Checks
- Dependencies
- Limitations and Differences
- What Is Unchanged from Upstream
- Contributing
- YAML Quick Reference
What Keysat is
Keysat lets a software seller issue, validate, and revoke license keys for their own product, with payment in Bitcoin via BTCPay Server. The seller runs Keysat on their own Start9, declares one or more products, and shares a public purchase URL with their customers. Buyers pay in Bitcoin and receive a signed license key whose authenticity their software can verify offline against the seller's embedded public key. Keys can be capped to specific machines, time-limited, suspended, revoked, or marked as trial.
Discount and referral codes (paid and free-license) are first-class primitives. Free-license codes bypass BTCPay entirely and issue a key directly via a public redemption endpoint — useful for press passes, comp keys, beta access, or "first N users free" launch promos.
Image and Container Runtime
Built from the local Dockerfile via images.main.source.dockerBuild,
with build context set to the parent directory so the Dockerfile can
COPY from the sibling licensing-service/ source tree. The Rust binary
is statically linked against musl (target
*-unknown-linux-musl) so the runtime image is a scratch-based final
stage with no shared-library dependencies. Architectures: x86_64 and
aarch64.
start-cli s9pk pack ingests the resulting OCI image, converts it to a
squashfs filesystem image, and embeds that in the .s9pk. At runtime
StartOS extracts the squashfs and runs the service in its own container
runtime.
Volume and Data Layout
Keysat declares a single persistent volume:
| Volume | Mount | Contents |
|---|---|---|
main |
/data |
SQLite database (keysat.db); contains the Ed25519 signing keypair, products, policies, licenses, machines, invoices, redemptions, audit log, and BTCPay credentials. |
Loss of this volume invalidates every issued license, since the signing keypair is regenerated on first boot. Treat StartOS-managed backups as mandatory.
Installation and First-Run Flow
- Install Keysat via the marketplace (or sideload the
.s9pk). - Resolve the auto-created critical task "Connect BTCPay" by running the Connect BTCPay action. This opens a one-click authorize page on your local BTCPay; after approval, Keysat auto-detects your store and registers an inbound webhook. No API keys to copy.
- Run Check BTCPay connection to confirm — the install task clears automatically.
- Set your operator name (shown on the public homepage and in buyer-facing receipts).
- Create one or more products — each represents something you sell.
- Create at least one policy per product. Multi-tier ladders (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. Policies define duration, grace period, seat cap, entitlements, recurring cadence, trial flag, price overrides, marketing bullets, and per-entitlement hide-on-buy-page toggles.
- Optionally create discount / referral / free-license codes (see
Create discount codeaction). - Share the public service URL with buyers.
Configuration Management
All configuration is performed through StartOS actions; there is no
on-disk config file the operator should edit. Environment variables
passed to the daemon at startup (main.ts) are derived from the
package-local store (operator name, admin API key) and from the
declared BTCPay dependency hostname.
For advanced operators, the /v1/admin/* HTTP API exposes everything
the actions do plus bulk-list operations not yet surfaced in the UI.
Retrieve the admin API key via the Show admin credentials action.
Network Access and Interfaces
Keysat exposes one logical port (8080 HTTP) split across two service interfaces for clarity:
| Interface | Type | Path prefix | Purpose |
|---|---|---|---|
api |
api | / |
Public REST API for buyers (purchase, redeem) and licensed apps (validate, machine activation). Bake the URL into your software builds as the licensing endpoint. |
webhook |
api | /btcpay |
BTCPay webhook landing endpoint. Registered automatically during Connect BTCPay; not for human use. |
StartOS terminates TLS at the platform edge. Inside the container every
request arrives as plain HTTP. For browser-facing URLs (e.g., the public
purchase page) hardcode https://.
Actions (StartOS UI)
Grouped as displayed in the dashboard.
General
- Set operator name — your public-facing brand.
BTCPay
- Connect BTCPay — one-click authorize against your BTCPay; auto-detects store and registers webhook.
- Check BTCPay connection — confirm BTCPay state; clears the install task on success.
Credentials
- Show admin credentials — admin API key for direct
/v1/admin/*access.
Products + Policies
- Create product — declare something to sell.
- Create policy — license template for a product (duration, grace, seat cap, entitlements, trial flag, price override).
Discount codes
- Create discount code — percent-off / fixed-sats-off / free-license.
- List discount codes — usage stats.
- Disable / enable discount code.
Licenses
- Issue license manually — comp / press / grandfathered keys.
- Search licenses — by email or BTCPay invoice id.
- Suspend license — reversible lockout.
- Unsuspend license.
- Revoke license — terminal kill.
Machines
- List machines — installs bound to a license.
- Deactivate machine — free a seat.
Webhooks (outbound)
- Register webhook endpoint — POST signed events to your URL.
- List webhook endpoints.
Diagnostics
- View audit log — admin mutation history, filterable.
Backups and Restore
Keysat opts into StartOS's default volume backup via setupBackups /
Backups.ofVolumes('main'). The single main volume contains all
state — signing key included — so a backup is sufficient to fully
recover the service. On restore, the install-time Connect BTCPay
task re-surfaces in case the BTCPay credentials in the restored DB are
stale.
Treat backups as mandatory: losing the signing keypair invalidates every key Keysat ever issued, with no recovery path.
Health Checks
A single port-listening check on port 8080 (sdk.healthCheck.checkPortListening).
StartOS reports the service as healthy once the daemon is binding the
port. The daemon exposes GET /healthz for richer external monitoring.
Dependencies
| Dependency | Version range | Required | Purpose |
|---|---|---|---|
btcpayserver |
>=1.11.0 |
Yes | Required to receive Bitcoin payments and confirm settlement. |
The dependency is kind: 'running', so Keysat will not start until
BTCPay is running. The btcpayserver.startos hostname is provided to
the container automatically.
Limitations and Differences
Known current limitations:
- Buyer self-service recovery is by-design minimal. Buyers can re-derive a lost license at
/recoverusing (invoice id, buyer email). They cannot transfer between machines without contacting the operator (use Free a machine seat in the admin / agent API). - No bulk / volume licensing UI. "Buy 10 keys at once with discount" is not built into the buy page. Operators can issue N comp licenses via the admin API in a loop.
- Webhook delivery retries are bounded. A subscriber down past the 10-attempt retry window lands in the dead-letter queue (visible in admin UI → Webhooks → Failed). BTCPay invoice reconciliation runs as a background poll so dropped payment webhooks are recovered.
- Hardware fingerprinting is client-supplied. Keysat does not derive fingerprints itself; the buyer-side SDK passes whatever the integrator chose. The fingerprint is bound on first activate and enforced thereafter.
- Card payments not shipped. The Zaprite payment provider is in design for v0.3 — operators on Pro / Patron will get a card-payment option alongside BTCPay. Until then, payments are BTC / Lightning only.
What Is Unchanged from Upstream
Not applicable — Keysat is authored fresh for Start9 and has no upstream.
The canonical implementation IS this package + the Rust daemon at
licensing-service/.
Contributing
For commercial redistribution or resale rights, or to discuss white-label
deployment, contact licensing@keysat.xyz. Source-available license
terms are in the package's LICENSE file: you may run, audit, modify
for self-hosting; you may not redistribute, resell, or publicly host for
others.
YAML Quick Reference
Structured summary for AI consumers and automated package introspection.
service:
id: keysat
title: Keysat
category: bitcoin
license: source-available (LicenseRef-Proprietary)
marketingUrl: https://keysat.xyz
image:
source: dockerBuild
baseImage: scratch (musl-static Rust binary)
arches: [x86_64, aarch64]
volumes:
- id: main
mountpoint: /data
contents: SQLite DB + Ed25519 signing keypair
network:
interfaces:
- id: api
type: api
port: 8080
protocol: http
pathPrefix: /
audience: public
- id: webhook
type: api
port: 8080
protocol: http
pathPrefix: /btcpay
audience: btcpay
dependencies:
btcpayserver:
required: true
versionRange: ">=1.11.0"
kind: running
healthChecks:
- id: api
method: portListening
port: 8080
backups:
mode: full-volume
volumes: [main]
firstRun:
tasks:
- id: btcpay-initial-setup
severity: critical
runs: configureBtcpay
features:
paymentRail: btcpay-server # zaprite planned for v0.3 (card payments)
signing: ed25519
offlineVerification: true
multiSeat: true
trialFlag: true
expiry: true
gracePeriod: true
entitlements: true
entitlementsCatalog: per-product # typed slugs with display names + descriptions
hiddenEntitlements: per-policy # license-granted but hidden from buy page
marketingBullets: per-policy # operator-authored ✓ items on tier cards
multiCurrency: [SAT, USD, EUR] # auto-converted at invoice creation
discountCodes: [percent, fixed_sats, set_price, free_license]
featuredDiscounts: true # launch-special, auto-applies on the buy page
multiPolicyDiscountScope: true # one code can apply to N policies
recurringSubscriptions: true # auto-renew with trials + grace
tierUpgrades: true # in-place tier upgrade with proration
outboundWebhooks: true
webhookDlq: true # failed deliveries retryable from admin UI
auditLog: true
scopedApiKeys: [read-only, license-issuer, support, full-admin]
openapiSpec: /v1/openapi.json
selfLicensingTier: [Creator, Pro, Patron]
sdks:
- typescript: "@keysat/licensing-client (npm)"
- rust: "keysat-licensing-client (crates.io)"
- python: "keysat-licensing-client (PyPI)"
- go: "github.com/keysat-xyz/keysat-client-go"