Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.
migrations/0021_invoice_provider_link.sql (new)
Adds invoices.payment_provider_id (nullable FK), backfills existing
pending/settled rows to the earliest-connected provider on the default
profile. Additive — no drops, no removals. Companion to 0020 from the
foundation commit.
models.rs
Invoice gains payment_provider_id: Option<String>.
db/repo.rs
row_to_invoice reads the new column. All three invoice SELECTs include
it. create_invoice + create_invoice_with_currency take a new optional
payment_provider_id parameter and persist it on INSERT.
subscriptions.rs
Subscription struct gains merchant_profile_id + payment_provider_id
(snapshotted on create). SUB_COLS + row_to_subscription + the manual
SELECT in find_lapsing_subscriptions all updated. create_subscription
accepts both new fields and writes them on the INSERT row.
renew_one — reads the sub's payment_provider_id snapshot and resolves
the provider via state.payment_provider_by_id(). Falls back to the
legacy state.payment_provider() for any subs created pre-:52 that
the migration backfill missed.
capture_zaprite_payment_profile — uses the INVOICE's provider, not
"the active one." Saved-profile ids are scoped per Zaprite org; using
the wrong provider would fail the lookup.
try_auto_charge_zaprite — uses the sub's snapshotted provider (same
rationale).
reconcile.rs
Per-invoice provider lookup. Each pending invoice is reconciled
against state.payment_provider_by_id(inv.payment_provider_id), with
graceful fallback for NULL provider ids. No more single-global-
provider assumption.
tipping.rs
Tip pay-out uses the provider that settled the license's purchase
invoice (joined via licenses.invoice_id). Same rationale as the
capture hook — the tip needs to draw from the right LN node.
api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
Tier-change invoices ride on existing licenses. The right provider
is whichever the license's subscription is snapshotted to (so the
proration charge settles to the same merchant identity that collects
renewal fees). Falls back to the invoice's recorded provider, then
the legacy default, for licenses with no subscription or pre-
snapshot rows.
api/purchase.rs
StartPurchaseReq gains an optional `rail` field
("lightning"/"onchain"/"card") for the future buy-page multi-rail
picker. When omitted (today's behavior), the daemon picks the first
rail the product's merchant profile exposes — which is correct for
single-provider operators AND back-compat for any pre-:52 client
not yet sending the field.
Provider resolution: product → merchant_profile → rail →
resolve_provider_for_profile_rail. The redirect_url defaults to the
profile's post_purchase_redirect_url (with {invoice_id} substitution)
if set, else Keysat's own /thank-you. New invoices carry their
provider's id via the new create_invoice_with_currency parameter.
api/webhook.rs
issue_license_for_invoice now passes snapshot fields when calling
subscriptions::create_subscription — both merchant_profile_id (from
product lookup) and payment_provider_id (from the invoice row).
main.rs
Replaces the legacy "active provider preference" boot loader with a
default-profile-first-provider warm-up. The legacy state.payment
singleton stays populated for back-compat with call sites that
haven't yet migrated to the on-demand resolution path. Pre-migration
fallback to the old singleton-config loaders preserved so the
daemon still boots cleanly on a DB that hasn't run 0020 yet.
Remaining for part 3:
- BTCPay + Zaprite connect flows take merchant_profile_id and
INSERT into payment_providers (currently still write to the
dropped singleton tables, broken post-migration).
- api/payment_provider.rs activate endpoint becomes irrelevant in
the new model — repurpose as list-providers, or delete.
- Thank-you page (api/mod.rs) provider-kind lookup ports to the
invoice's recorded provider.
- Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
- Admin UI for Merchant Profiles + product picker + buy-page brand
block + rail picker.
- Tier-cap wire-up for unlimited_merchant_profiles entitlement.
- Version bump to :52 + release notes.
Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keysat
Keysat is a Bitcoin-native, self-hosted licensing service for software creators, designed to run as a Start9 0.4.0.x service alongside BTCPay Server (or Zaprite for Bitcoin + cards). One instance can sell, issue, validate, and revoke licenses for any number of software products you own.
The repository directory is still called
licensing-service/on disk for continuity with earlier revisions. The crate, the binary, the StartOS package id, and all user-visible strings use Keysat.
Every developer who uses this runs their own instance on their own hardware. There is no central authority, no shared database, and no dependency on anyone else's servers. Your keys, your products, your customers, your rules.
What it does
- Exposes a REST API for selling and managing software licenses paid for in Bitcoin via BTCPay Server.
- Issues Ed25519-signed license keys that can be verified offline by any client with your server's public key — so downstream software doesn't break if your licensing server is briefly unreachable.
- Supports multiple products per instance, each with independent pricing and license pools.
- Supports closed-source, open-source-for-convenience, and open-core distribution models. The service doesn't care how you distribute source; it only validates keys against products.
- Optional per-license machine fingerprint binding with trust-on-first-use.
- Admin-gated endpoints for product management, manual license issuance (comps/press/testing), and revocation.
Architecture in two minutes
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Buyer's │──────▶│ licensing-service │──────▶│ BTCPay Server│
│ browser │ │ (this program) │ │ (Start9) │
└──────────────┘ └──────────────────────┘ └──────────────┘
▲ │ ▲ │
│ license key │ │ webhook │
│ ▼ │ │
│ ┌──────────────┐ │
└─────────────────│ SQLite │◀──────────────────┘
poll/status │ licensing.db
└──────────────┘
Downstream software (e.g. another Start9 package you sell):
on startup → POST /v1/validate { key, product_slug, fingerprint }
→ caches result, re-checks on reasonable cadence
- Buyer
POST /v1/purchase { product: "my-app" }→ we create a BTCPay invoice, return its checkout URL. - Buyer pays via BTCPay. BTCPay fires a signed webhook at
POST /v1/btcpay/webhook→ we mark the invoice settled and issue a license row. - Buyer polls
GET /v1/purchase/:invoice_id→ once settled, response contains the signedlicense_keystring. - Buyer installs the software. On startup the software calls
POST /v1/validateto check revocation and bind itself to the installation.
Why Ed25519-signed keys
Each license key is a compact, cryptographically signed envelope:
LIC1-<74-byte payload, base32>-<64-byte signature, base32>
The payload contains the product id, license id, issue time, an optional fingerprint hash, and a version byte. The server's private key signs it; anyone with the public key can verify it.
The practical benefit: downstream software can verify a key's signature offline, using a public key bundled at compile time. It only needs to reach your licensing server to check revocation, and it can cache that check. If your licensing server has an outage, existing installations keep working. If someone tries to forge a key, the signature fails instantly without a database lookup.
See src/crypto/mod.rs for the exact byte layout.
Project layout
licensing-service/
├── Cargo.toml
├── LICENSE # source-available; no redistribution
├── README.md
├── .env.example # required env vars
├── migrations/
│ └── 0001_initial.sql # SQLite schema
├── src/
│ ├── main.rs # entry point: wires everything
│ ├── config.rs # env-driven config
│ ├── error.rs # unified error → HTTP mapping
│ ├── models.rs # shared domain types
│ ├── crypto/
│ │ ├── mod.rs # license key format + sign/verify
│ │ └── keys.rs # server keypair lifecycle
│ ├── db/
│ │ ├── mod.rs # pool + migrations
│ │ └── repo.rs # all SQL queries
│ ├── btcpay/
│ │ ├── client.rs # Greenfield API client
│ │ └── webhook.rs # HMAC verification + event parsing
│ └── api/
│ ├── mod.rs # router + AppState
│ ├── products.rs # public product endpoints
│ ├── purchase.rs # buy + poll
│ ├── validate.rs # the hot path for downstream software
│ ├── webhook.rs # BTCPay landing
│ └── admin.rs # operator-only actions
└── docs/
├── API.md # full endpoint reference
├── INTEGRATION.md # for developers embedding a client
└── ARCHITECTURE.md # deeper design notes
Running locally
Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted).
cp .env.example .env
# edit .env — generate admin key with: openssl rand -hex 32
# fill in BTCPay URL, API key, store id, webhook secret
cargo run --release
On first boot the server generates a fresh Ed25519 keypair and stores it in the SQLite database. Get the public key anytime from GET /v1/pubkey (or from the logs on first boot).
Creating your first product
curl -X POST http://localhost:8080/v1/admin/products \
-H "Authorization: Bearer $LICENSING_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "my-app",
"name": "My App",
"description": "A cool Start9 service.",
"price_sats": 50000
}'
Walking through a purchase
# 1. Buyer starts a purchase
curl -X POST http://localhost:8080/v1/purchase \
-H "Content-Type: application/json" \
-d '{"product": "my-app"}'
# → { "invoice_id": "...", "checkout_url": "https://btcpay.../i/...", ... }
# 2. Buyer opens checkout_url, pays
# 3. Buyer polls
curl http://localhost:8080/v1/purchase/<invoice_id>
# → { "status": "settled", "license_key": "LIC1-...", ... }
# 4. Downstream software validates the key
curl -X POST http://localhost:8080/v1/validate \
-H "Content-Type: application/json" \
-d '{"key": "LIC1-...", "product_slug": "my-app", "fingerprint": "host-abc123"}'
# → { "ok": true, "license_id": "...", "product_id": "..." }
Deploying on Start9
This repository ships the service only. To package as an .s9pk for the 0.4.0.x platform you'll need a separate wrapper repository following docs.start9.com/packaging/0.4.0.x. The service is designed to slot in cleanly:
- Declares a dependency on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a
.startoshostname and supply the env vars from the wrapper's action handlers. - Persists to
/data, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups. - Binds to
0.0.0.0:8080and expects StartOS to handle Tor/LAN/clearnet exposure. - Graceful shutdown on SIGTERM, as StartOS expects.
- Environment-driven config, no config files needed at runtime.
When you're ready to write the manifest, the env vars you need to wire are listed in .env.example. The main gotcha is the BTCPay webhook secret: you configure it on the BTCPay side and it must match BTCPAY_WEBHOOK_SECRET exactly — we verify HMAC-SHA256 in constant time and reject any mismatch.
Developer integration
If you're a developer shipping software that should validate against a licensing-service instance, see docs/INTEGRATION.md. It covers:
- Bundling the server's public key in your client.
- Offline signature verification + online revocation check.
- Graceful handling of server outages (don't brick your users).
- Recommended caching and rate-limiting patterns.
Source-available licensing
This project is source-available, not open source. You may read, audit, self-host, and modify for your own use, but may not redistribute, resell, or publicly host for others. See LICENSE for the full terms.
Commercial redistribution / resale rights: contact licensing@keysat.xyz.
Status
v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator.