KEYSAT_INTEGRATION.md: section 11a — tier-aware purchases + in-app picker
Documents the multi-policy in-app purchase flow that the Recap dev hit a dead-end on (no obvious tier discriminator on startPurchase). Adds: - New section 11a "Tier-aware purchases — in-app tier picker (multi-tier products)" walking the full pattern: listPublicPolicies → render tier UI → startPurchase with policySlug → open checkout → poll/webhook → write key. Same shape in TS / Python / Rust / Go. - Architecture diagram showing buyer → SDK → daemon → BTCPay → key. - "When you'd use this" guidance + "Common mistakes" section including the four traps the Recap dev guessed at: hardcoding slugs, splitting products, abusing discount codes as tier selectors, omitting policySlug. - Cross-reference from question 7 in section 0 (the operator- questionnaire) so the LLM nudges toward the picker pattern when there are 2+ tiers, and back to single-tier section 11 otherwise. - Cross-reference from section 7f (frontend integration for hard-gate Flavor 2) so the activation-screen pattern surfaces the picker as an inline option. - Cross-reference from section 11 → 11a so single-policy readers who later add tiers find the upgrade path. This is the pattern Recap implements in its activation screen, and becomes the canonical example for any future multi-tier integration. SDKs (TS, Rust, Python, Go) all support it as of their 0.2.0 releases (commits c3a57a0 / 5dd301c / 94654f6 / 970f95a in their respective repos).
This commit is contained in:
@@ -75,6 +75,16 @@ hangs on these:
|
|||||||
for the integration itself, but useful for shaping the "Upgrade"
|
for the integration itself, but useful for shaping the "Upgrade"
|
||||||
message that shows when an unlicensed user hits a paid feature.)
|
message that shows when an unlicensed user hits a paid feature.)
|
||||||
|
|
||||||
|
Two-or-more-tier products unlock a UX option: an **in-app tier
|
||||||
|
picker** that renders the buyer's options inside the operator's
|
||||||
|
own UI (e.g. on the activation screen) and drives the purchase
|
||||||
|
programmatically through the SDK, instead of redirecting to the
|
||||||
|
externally-hosted `/buy/<slug>` page. See section 11a — this is
|
||||||
|
often the strongest fit when the app already has a settings or
|
||||||
|
activation surface where "Choose a plan" feels native. If there's
|
||||||
|
only one tier (or only one *paid* tier), skip this and use the
|
||||||
|
simpler single-policy flow in section 11.
|
||||||
|
|
||||||
If the creator doesn't know yet, propose sensible defaults from the
|
If the creator doesn't know yet, propose sensible defaults from the
|
||||||
ranges above and confirm before coding.
|
ranges above and confirm before coding.
|
||||||
|
|
||||||
@@ -945,6 +955,16 @@ return <App />
|
|||||||
generic "activation failed"
|
generic "activation failed"
|
||||||
- A "Buy a key" link to `${keysatBaseUrl}/buy/${productSlug}` (see §3)
|
- A "Buy a key" link to `${keysatBaseUrl}/buy/${productSlug}` (see §3)
|
||||||
|
|
||||||
|
**Optional — embed the tier picker directly in the activation card.**
|
||||||
|
For multi-tier products, instead of (or in addition to) the "Buy a
|
||||||
|
key" link, render an inline tier picker that lets the buyer pay
|
||||||
|
without leaving your app. Calls
|
||||||
|
`Client.listPublicPolicies(productSlug)` to render the tier list and
|
||||||
|
`Client.startPurchase(productSlug, { policySlug })` to drive the
|
||||||
|
checkout. The full pattern, including the architecture diagram and
|
||||||
|
common mistakes, is in **section 11a**. This is the pattern Recap
|
||||||
|
ships in their activation screen.
|
||||||
|
|
||||||
**Step 4: Gate Pro features in the UI, not just the server.** The
|
**Step 4: Gate Pro features in the UI, not just the server.** The
|
||||||
server returns 402 for missing entitlements, but unless the frontend
|
server returns 402 for missing entitlements, but unless the frontend
|
||||||
also checks, users see ghost UI for features they can't use:
|
also checks, users see ghost UI for features they can't use:
|
||||||
@@ -1140,6 +1160,202 @@ The simpler alternative: just link to the operator's buy page and let
|
|||||||
them complete the purchase on the web, then paste the resulting key
|
them complete the purchase on the web, then paste the resulting key
|
||||||
into your app's settings. Less integrated, less friction to implement.
|
into your app's settings. Less integrated, less friction to implement.
|
||||||
|
|
||||||
|
If the product has **two or more public policies** (Core/Pro, Free/
|
||||||
|
Standard/Pro, etc.), see section 11a for the tier-aware flow that
|
||||||
|
lets buyers pick a tier inside your app's own UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11a. Tier-aware purchases — in-app tier picker (multi-tier products)
|
||||||
|
|
||||||
|
When a product has multiple public policies, the buyer needs to **pick
|
||||||
|
which tier they're paying for** before the invoice is created. Section
|
||||||
|
11's `startPurchase(slug, { buyerEmail })` defaults to the product's
|
||||||
|
"default" policy (or the first active one), which works fine for
|
||||||
|
single-tier products but always issues a Core license on a Core/Pro
|
||||||
|
setup — no matter what the buyer wanted.
|
||||||
|
|
||||||
|
The fix has two pieces, both supported by the SDK since 0.2.0:
|
||||||
|
|
||||||
|
1. **`Client.listPublicPolicies(productSlug)`** — fetches the buyer-
|
||||||
|
visible tier list from `GET /v1/products/<slug>/policies`. Public
|
||||||
|
endpoint, no auth. Returns each tier's slug, display name, price
|
||||||
|
(in the product's listed currency's smallest unit — sats for SAT,
|
||||||
|
cents for USD/EUR), entitlements, recurring/trial flags, and the
|
||||||
|
"Most popular" highlight flag. Render this into your tier-picker
|
||||||
|
UI; it'll stay in sync if the operator adds/edits tiers in Keysat
|
||||||
|
admin without you redeploying the app.
|
||||||
|
2. **`policySlug` field on `startPurchase`'s options** — when set, the
|
||||||
|
licensing service prices the invoice at that policy's
|
||||||
|
`price_sats_override` and the issued license carries that policy's
|
||||||
|
entitlements, duration, max_machines, and trial flag.
|
||||||
|
|
||||||
|
### When you'd use this
|
||||||
|
|
||||||
|
- Multi-tier products where the choice happens in the buyer's app
|
||||||
|
(activation screen, settings, in-app upgrade banner). Common shape:
|
||||||
|
freemium app where Free is gated by `core` entitlement and Pro
|
||||||
|
unlocks `subscriptions, history, library`.
|
||||||
|
- Operators who want to add or rename tiers without forcing an app
|
||||||
|
update — the picker rebuilds itself off `listPublicPolicies`.
|
||||||
|
- Apps that need to write the issued license key directly to disk
|
||||||
|
themselves (e.g. via a backend service, not via copy-paste from
|
||||||
|
the buy page). The SDK delivers the signed key as a string; you
|
||||||
|
write it where you want.
|
||||||
|
|
||||||
|
### Pattern (TypeScript / web app frontend)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Client, PublicPolicy } from '@keysat/licensing-client'
|
||||||
|
|
||||||
|
const client = new Client('https://licensing.example.com')
|
||||||
|
|
||||||
|
// 1. Fetch tiers — typically on activation screen mount.
|
||||||
|
const { product, policies } = await client.listPublicPolicies(PRODUCT_SLUG)
|
||||||
|
|
||||||
|
// 2. Render `policies` into your tier-picker UI. Each policy carries
|
||||||
|
// everything you need to display:
|
||||||
|
function renderTier(p: PublicPolicy) {
|
||||||
|
return `
|
||||||
|
<button data-slug="${p.slug}" class="tier ${p.highlighted ? 'popular' : ''}">
|
||||||
|
<h3>${p.name}</h3>
|
||||||
|
<p>${p.description}</p>
|
||||||
|
<div class="price">${formatPrice(p.priceSats, product /* for currency */)}
|
||||||
|
${p.isRecurring ? '/' + cadence(p.renewalPeriodDays) : ''}</div>
|
||||||
|
<ul>${p.entitlements.map(e => `<li>${e}</li>`).join('')}</ul>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Buyer picks a tier; you call startPurchase with policySlug.
|
||||||
|
async function buyTier(chosenSlug: string, buyerEmail: string) {
|
||||||
|
const session = await client.startPurchase(PRODUCT_SLUG, {
|
||||||
|
policySlug: chosenSlug, // <-- the discriminator
|
||||||
|
buyerEmail,
|
||||||
|
redirectUrl: 'https://your-app.example/thank-you',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Open the checkout URL. For desktop apps, `open(session.checkoutUrl)`.
|
||||||
|
// For web apps, `window.location.href = session.checkoutUrl`.
|
||||||
|
window.location.href = session.checkoutUrl
|
||||||
|
|
||||||
|
// 5. After payment settles, your backend (or the buyer's poll) hits
|
||||||
|
// /v1/purchase/<invoice_id> and gets the signed license_key.
|
||||||
|
// Write it to wherever your app reads from. Reload validate.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern (other languages — same shape)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python
|
||||||
|
from keysat_licensing_client import Client, StartPurchaseOptions
|
||||||
|
|
||||||
|
client = Client('https://licensing.example.com')
|
||||||
|
tiers = client.list_public_policies(PRODUCT_SLUG)
|
||||||
|
|
||||||
|
# render tiers.policies in your UI; user picks "pro"
|
||||||
|
session = client.start_purchase(PRODUCT_SLUG, StartPurchaseOptions(
|
||||||
|
policy_slug='pro',
|
||||||
|
buyer_email='buyer@example.com',
|
||||||
|
))
|
||||||
|
# open session.checkout_url; poll on settle
|
||||||
|
key = client.wait_for_license(session.invoice_id, timeout_s=30*60)
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Rust
|
||||||
|
use licensing_client::{Client, StartPurchaseOptions};
|
||||||
|
let client = Client::new("https://licensing.example.com")?;
|
||||||
|
let tiers = client.list_public_policies(PRODUCT_SLUG).await?;
|
||||||
|
// render tiers.policies; user picks "pro"
|
||||||
|
let session = client.start_purchase(PRODUCT_SLUG, &StartPurchaseOptions {
|
||||||
|
policy_slug: Some("pro"),
|
||||||
|
buyer_email: Some("buyer@example.com"),
|
||||||
|
..Default::default()
|
||||||
|
}).await?;
|
||||||
|
// open session.checkout_url; poll on settle
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go
|
||||||
|
client := keysat.NewClient("https://licensing.example.com", nil)
|
||||||
|
tiers, _ := client.ListPublicPolicies(ctx, PRODUCT_SLUG)
|
||||||
|
// render tiers.Policies; user picks "pro"
|
||||||
|
session, _ := client.StartPurchase(ctx, PRODUCT_SLUG, keysat.StartPurchaseOptions{
|
||||||
|
PolicySlug: "pro",
|
||||||
|
BuyerEmail: "buyer@example.com",
|
||||||
|
})
|
||||||
|
// open session.CheckoutURL; poll on settle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common mistakes
|
||||||
|
|
||||||
|
- **Hardcoding policy slugs in the client.** The whole point of
|
||||||
|
`listPublicPolicies` is that the operator owns the tier shape. If
|
||||||
|
you ship a build that only knows about Core and Pro, and the
|
||||||
|
operator adds a "Patron" tier next month, the picker is silently
|
||||||
|
stale. Render the picker off the live API response.
|
||||||
|
- **Splitting a product into multiple products.** Don't. Different
|
||||||
|
tiers of the same product share the product slug and differ only on
|
||||||
|
the policy slug. Splitting breaks `validate()` calls from clients
|
||||||
|
that expect one canonical `productSlug`. The whole tier system is
|
||||||
|
built on the assumption of one product, many policies.
|
||||||
|
- **Using discount codes as a tier discriminator.** `code` is for
|
||||||
|
promos and referral discounts. It can't change which tier the buyer
|
||||||
|
ends up on. Use `policySlug`.
|
||||||
|
- **Forgetting `policySlug` and assuming the right tier.** With
|
||||||
|
`policySlug` omitted, the daemon picks the policy slugged "default"
|
||||||
|
(if any), else the first active one. On a Core/Pro setup where
|
||||||
|
Core happens to be alphabetically first or named "default", every
|
||||||
|
buyer who hits your in-app upgrade flow without a `policySlug` ends
|
||||||
|
up on Core regardless of what they clicked. Always pass the slug
|
||||||
|
the buyer chose.
|
||||||
|
- **Copying the price from your hardcoded UI rather than the API.**
|
||||||
|
Operators legitimately edit tier pricing in admin without warning;
|
||||||
|
if you cache a price, you'll under- or over-charge buyers vs. what
|
||||||
|
they actually pay. Render `policy.priceSats` directly from the
|
||||||
|
current `listPublicPolicies` response.
|
||||||
|
|
||||||
|
### Architecture diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Buyer in your app
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
listPublicPolicies(slug) ← public, no auth
|
||||||
|
│
|
||||||
|
│ returns [{slug, name, priceSats, entitlements, ...}, ...]
|
||||||
|
▼
|
||||||
|
your in-app tier picker UI ← operator's branding
|
||||||
|
│
|
||||||
|
│ buyer clicks "Pro"
|
||||||
|
▼
|
||||||
|
startPurchase(slug, {policySlug: 'pro', buyerEmail, redirectUrl})
|
||||||
|
│
|
||||||
|
│ returns {checkoutUrl, invoiceId, ...}
|
||||||
|
▼
|
||||||
|
open checkoutUrl in browser ← BTCPay or Zaprite
|
||||||
|
│
|
||||||
|
│ buyer pays
|
||||||
|
▼
|
||||||
|
operator's licensing service ← webhook fires on settle
|
||||||
|
│
|
||||||
|
│ issues license with Pro entitlements + invoice.policy_id = 'pro'
|
||||||
|
▼
|
||||||
|
poll /v1/purchase/<id> OR webhook to your backend
|
||||||
|
│
|
||||||
|
│ returns license_key (signed string)
|
||||||
|
▼
|
||||||
|
write to /data/license.txt (or your chosen path)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
checkLicense() reloads, app sees Pro entitlements
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same architecture Keysat itself uses for its own
|
||||||
|
self-licensing (cf section 17) and the same flow Recap implements
|
||||||
|
in their Recap app's activation screen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. UX patterns for revocation & errors
|
## 12. UX patterns for revocation & errors
|
||||||
|
|||||||
Reference in New Issue
Block a user