# LicenseSeat Documentation
> Complete documentation for LicenseSeat - Easy, secure licensing server and license keys for your games, plugins, and apps
## Documentation
- [LicenseSeat Documentation](/)
- [The Agentic Integration Playbook](/guides-agentic-integration-playbook/)
- [Air-Gapped Licensing](/guides-air-gapped-licensing/)
- [Offline Licensing](/guides-offline-licensing/)
- [Operating sales connections](/guides-sales-platform-operations/)
- [Shipping Desktop Updates](/guides-shipping-desktop-updates/)
- [Guides](/guides/)
- [Entitlements](/entitlements/)
- [Create a license](/api-reference-create-license/)
- [Generate Download Token](/api-reference-download-token/)
- [API Quickstart](/api-reference/)
- [Get Signing Key](/api-reference-signing-keys/)
- [Telemetry](/api-reference-telemetry/)
- [Selling software licenses on Gumroad](/software-licensing-api-selling-license-keys-on-gumroad/)
- [Selling software licenses on Lemon Squeezy](/software-licensing-api-selling-license-keys-on-lemon-squeezy/)
- [Selling software licenses with Paddle](/software-licensing-api-selling-license-keys-with-paddle/)
- [Selling software licenses on Polar.sh](/software-licensing-api-selling-license-keys-on-polar-sh/)
- [Selling software licenses on Shopify](/software-licensing-api-selling-license-keys-on-shopify/)
- [Selling software licenses with Stripe Payment Links](/software-licensing-api-selling-license-keys-with-stripe/)
- [C++ SDK](/sdks-cpp/)
- [C# SDK](/sdks-csharp/)
- [Interactive SDK Demo](/sdks-demo/)
- [SDKs](/sdks/)
- [JavaScript SDK](/sdks-javascript/)
- [Rust SDK](/sdks-rust/)
- [Swift SDK](/sdks-swift/)
---
## Full Content
## LicenseSeat Documentation
LicenseSeat is a licensing server for games, plugins, and apps.
It allows you to easily issue license keys, validate them in your software, and manage activations, all without rolling your own infrastructure.
## The end goal
The end goal is simple: to have a box in your app where users can enter their license key:
```
┌────────────────────────────────────────────┐
│ Enter your license key: │
│ ┌──────────────────────────────────────┐ │
│ │ XXXX-XXXX-XXXX-XXXX │ │
│ └──────────────────────────────────────┘ │
│ [Activate] │
└────────────────────────────────────────────┘
```
Example: License activation in the SynthDemo app (C++ SDK)
Which only allows access to legitimate users who have bought the product and have an active license. You just need to provide the UI box, and we'll handle everything else!
## Connect your app to LicenseSeat
To get everything wired and ready, you just need to add the LicenseSeat SDK to your app / game / plugin!
It's super easy and takes less than 10 lines of code:
```javascript
import LicenseSeat from '@licenseseat/js';
// 1. Initialize the SDK
const sdk = new LicenseSeat({
apiKey: 'pk_live_xxxxxxxxxxxxxxxxxxxxx',
productSlug: 'my-product'
});
// 2. Activate a license
await sdk.activate('TEST-XXXX-XXXX-XXXX');
console.log('License activated!');
```
We provide SDKs for the most popular platforms for apps, games, and plugins:
| Platform | Package | Stable version |
| --------------------------- | ------- | -------------- |
| **JavaScript / TypeScript** | [[sdks/javascript\|@licenseseat/js]] | 0.5.0 |
| **Swift** | [[sdks/swift\|licenseseat-swift]] | 0.4.2 |
| **C#** | [[sdks/csharp\|licenseseat-csharp]] | 0.5.0 |
| **C++** | [[sdks/cpp\|licenseseat-cpp]] | 0.6.0 |
| **Rust / Tauri** | [[sdks/rust\|licenseseat + tauri-plugin-licenseseat]] | 0.6.0 |
All SDKs support:
- License activation and deactivation
- Automatic background checks
- Heartbeats + app telemetry so you can get analytics about your product and users
- [[guides/offline-licensing|Offline validation]] — works on planes, in China, anywhere without internet
- [[entitlements|Entitlements]] — feature flags tied to licenses
- And much more
See the [[sdks/index\|SDK documentation]] for detailed integration guides.
## Or use the API directly
If we don't have an SDK for your language, or if you just want to use the API directly, you can do so easily!
There's extensive API documentation, along with the OpenAPI spec etc available at the [LicenseSeat API docs](/api).
## One last thing: connect your payments platform!
You're probably already selling your product through Stripe, Gumroad, Lemon Squeezy, Shopify, etc.
You can automate license issuing so that every time a new customer purchases your product, LicenseSeat automatically issues and emails a new license for them!

Here's how it works:
1. User purchases your app → You receive the payment via Stripe/Gumroad/etc. as usual
2. LicenseSeat receives the purchase event from Stripe/Gumroad/etc. → We issue a license key automatically
3. User receives license key via email with your own product branding
Here's what your fully branded email (sent by LicenseSeat to your customer) looks like:

## You're ready to get started!
If you haven't already, [sign up on LicenseSeat](https://licenseseat.com/users/sign_up), go through the onboarding, and you'll get your API key and everything needed to add LicenseSeat to your app!
## Next steps
When you're ready:
- [[sdks/index|SDK Documentation]] — Integrate your app with LicenseSeat
- [[guides/index|Guides]] — In-depth guides for offline licensing, payment integrations, and more
- [[api-reference/index|API Reference]] — Direct API access for custom integrations
---
## The Agentic Integration Playbook
# The Agentic Integration Playbook
This is the end-to-end recipe for taking a desktop app from **unlicensed** to
**licensed, demo-gated, and self-distributing** on LicenseSeat — written so
that a coding agent can execute it, and a human can audit it.
It is not a tutorial for a toy app. Every recipe, contract, and pitfall here
was extracted from a real integration: **Clipbasket**, a macOS/Windows
clipboard manager (Tauri + Rust) that was taken through this exact path —
first-run email wall, 14-day full-power demo, hard-paywall decay that stays
resident and capturing, paid perpetual license with one year of updates,
signed update feeds, and
license-gated downloads — with the bulk of the implementation done by coding
agents and every load-bearing change human-reviewed. Where this playbook says
"do X, not Y", it is because Y was tried and failed in a specific,
reproducible way.
The playbook is deliberately prescriptive. An agent implementing a paywall
from "it depends" ships a paywall with a hole in it.
This playbook covers the full machine: server model, wire contracts, client
engine, the licensing UX layer, distribution, email capture, verification,
and operations.
---
## 1. The Model
### 1.1 Three primitives, no new machinery
Everything below composes from three server-side primitives you already have:
| Primitive | What it is | The fields that matter here |
| --- | --- | --- |
| **License** | The unit of access. Validates, activates (consumes a seat), heartbeats, gets machine files for offline use, revokes. | `status`, `ends_at`, `metadata`, `active_entitlements` |
| **Plan** (`LicensePlan`) | The template a license is issued from. | `duration_days` (absent = perpetual), `seat_limit`, `entitlements` (the feature contract — see §2.1) |
| **Entitlement** | A feature key a license carries, optionally with numeric caps and its own duration. | `key`, cap fields (`limit`, `max_clips`, …), `duration_type: perpetual \| fixed` |
The single most important consequence: **a demo license is just a license.**
It is issued from a plan the product designates as its demo plan
(`duration_days: 14`, `seat_limit: 1`, full entitlements). It activates,
validates, heartbeats, expires, and gets machine files exactly like a paid
license. There is zero demo-specific license machinery to build client-side —
the client implements *tiers* (below), and the server decides which tier a
license grants.
### 1.2 The three-tier funnel
The funnel that shipped, and the state machine that enforces it:
```
first launch demo key activated demo expires
│ │ │
▼ ▼ ▼
┌──────────┐ email+key ┌──────────────┐ time passes ┌──────────┐
│ WALL │ ─────────────▶ │ DEMO │ ─────────────▶ │ DECAY │
│ (locked, │ │ (full power, │ │ (paywall,│
│ selling)│ │ DEMO badge, │ │ +capture,│
└──────────┘ │ countdown) │ │ selling)│
│ └──────┬───────┘ └────┬─────┘
│ paid key │ paid key over demo │ paid key
└────────────────────────────┴──────────────┬──────────────┘
▼
ENTITLED (paid)
```
- **Wall** — nothing was ever activated on this install. The product surface
is closed; one window offers both routes in: request a free demo key by
email, or paste a key you already have. The wall captures 100% of activated
users' emails, because install-and-open already filtered for intent.
- **Demo** — the license grants **the full product for 14 days**. PRO
features *work* at full power (**badge, don't lock**), and the visible
countdown pill reads **"DEMO — N days remaining"**: DEMO names the free
window; **PRO** stays reserved for the paid tier the PRO-labeled pills
advertise (§4.5), so "PRO" never names a temporary state. The trial stays
maximally competitive while the tier and the deadline stay visible — and
the deadline creates the urgency that capability caps alone never produce.
- **Decay** — the demo (or a paid license) expired. Decay is a **hard
paywall over a still-running app**: the popup surface is licensed-only,
and every surface — tray icon, tray menu, global shortcut, second launch —
routes to the license window, which drops the free-demo offer and leads
with buy. Meanwhile **capture continues in the background**: history keeps
accruing to the user's own retention setting and is never deleted, so
purchasing restores the full accumulated history — including everything
captured while lapsed. **Never brick, never delete** (§1.3). (This
reverses an earlier capped-but-usable decay design: the shipped behavior
is [clipbasket #9](https://github.com/rameerez/clipbasket/pull/9), and
[licenseseat #51](https://github.com/rameerez/licenseseat/pull/51) amended
the PRD to match.)
- **Entitled (paid)** — perpetual license, full entitlements, plus an
`updates` entitlement with a fixed 12-month window (§5.5).
Two derived rules the client engine must satisfy:
1. **The tier is derived in exactly one place, natively**, from one coherent
SDK state snapshot. The UI renders a projection of the tier; it never
decides the tier.
2. **Capture and surface are separate gates** (§4.2), and expiry closes only
the surface — retention is never touched (§1.3).
### 1.3 Hides, never deletes
The single highest-stakes design rule in the funnel, learned the hard way (an
early implementation fed the decay cap into the history *prune* path; a test
copy destroyed 29 of 36 rows before the design was caught and fixed, and the
fix was locked in with a regression test):
> **Expiry is a visibility event, not a retention event.**
In decay, the entire product surface is hidden (§1.2) while capture keeps
retaining to the user's own history setting. Underneath the paywall, the
engine's decay caps still exist as its fail-closed floor (§3.5), and they
are enforced at the **read/admission layer only**: existing saved and pinned
rows are never unsaved, unpinned, or deleted by a licensing transition, and
no cap constant may feed a prune. Re-licensing reopens the surface and
reveals the full history **byte-identical** — plus everything captured while
lapsed.
Why this is worth a hard rule and a locked test
(`history_survives_licensed_decay_copy_relicense_round_trip_byte_identical`
in Clipbasket's `src-tauri/src/licensing.rs`):
- A bricked app gets uninstalled and the captured email goes cold. A
resident, still-capturing app stays installed and keeps selling — every
muscle-memory shortcut press re-presents the paywall (§4.3).
- "Unlock the rest" must be *literally true* — deleting a lapsed customer's
data poisons the exact upgrade you are selling.
- The decay constant must **never feed a destructive prune path**, because a
*transient* invalid state (network flap, clock issue) resolves through the
same tier — and a prune there is irreversible data loss no renewal can
undo. (A restore in flight does not even resolve through a tier; it is
undecided and applies nothing — §3.5.)
### 1.4 Flow A vs Flow B: the acquisition posture
The server machinery is identical for both; the difference is seller policy,
stored per product as `demo_gate: open | first_run | download`:
- **Flow A — frictionless-first:** download is open, the app runs immediately
under tight stage-0 caps, and the email prompt appears at first cap-hit.
Email is captured at the moment of demonstrated interest.
- **Flow B — email-gated:** the demo license *is* the entry ticket. Either
*gated first-run* (app downloads freely but launches into the wall —
stage 0 is locked, not capped; purely a client posture) or *gated download*
(the site offers no direct binary; the demo email itself carries the
download link).
Clipbasket chose **Flow B, gated first-run**. An agent implementing either
flow builds the same tier engine — only the Wall tier's reachability differs.
---
## 2. Contracts an Agent Must Know
These are the wire-contract facts that cost real debugging time. Each one
looks like a detail until it silently zeroes out a feature.
### 2.1 Entitlement caps are TOP-LEVEL plan-entry fields
**The contract:** a plan's `entitlements` array holds hashes with a `"key"`
and the caps as **sibling top-level fields**:
```json
[
{ "key": "ocr" },
{ "key": "unlimited_history", "max_clips": 1000 },
{ "key": "saved_items", "limit": -1 },
{ "key": "pins", "limit": 3 }
]
```
**Why:** at issuance, every entry field other than `key` / `duration_type` /
`duration` is copied into the issued entitlement's `metadata`. The client
then reads caps **single-nested** at `active_entitlements[].metadata.max_clips`
/ `.metadata.limit`.
**The pitfall (hit in the real integration, then fixed by contract):** if you
write the caps inside an entry-level `"metadata"` hash —
`{"key": "pins", "metadata": {"limit": 3}}` — they surface **double-nested**
as `active_entitlements[].metadata.metadata.limit`, and every client cap read
silently misses. The E2E harness (§7) caught this; the contract was rewritten
to top-level fields and re-proven end to end.
**The second pitfall in the same field:** entries that are **not** hashes
with a `"key"` (e.g. bare strings) used to be silently dropped at issuance —
the plan looked configured, and every license issued from it carried **zero
entitlements** (downstream symptom: download tokens refused with
`not_entitled`). Since engine 0.7.0 the plan validates entries and rejects
malformed ones with a per-entry error. If you are on an older engine, the
drop is silent: always verify issued entitlements in a `validate` response,
never trust the plan editor alone.
**The convention that falls out:** `-1` means unlimited, absent means the
client's own default. Keep cap semantics (what absent means, what `-1`
means) in the client's single caps function, not scattered at call sites.
### 2.2 The `license.metadata` mirror for plugin clients
The API enriches validate/activate payloads for demo licenses with two
top-level fields:
```json
"demo": true,
"upgrade_url": "https://buy.stripe.com/..."
```
**The pitfall:** typed SDK clients (the pinned Tauri/Rust plugin, for
instance) parse license payloads into structs and **drop unknown top-level
fields** — so the enrichment is unreadable exactly where it is needed to
render a native Upgrade button. **The fix is a mirror:** at demo issuance the
server also writes `demo: true` + `upgrade_url` into `license.metadata`,
which typed clients *do* surface. The top-level payload fields remain the
source of truth for API consumers; the metadata copy is the plugin-readable
mirror.
**Snapshot semantics an agent must respect:** the metadata `upgrade_url` is a
**snapshot taken at issue time** — it is not rewritten on resend or refreshed
on validate. If the seller changes the payment link later, already-issued
demo licenses keep the old URL in metadata (the live top-level enrichment
updates). Client rule: prefer the freshest source available to you, and
always keep a fallback to your marketing site so a stale or absent URL never
strands the buy button.
### 2.3 Neutral 202: the demo endpoint never confirms anything
`POST /api/v1/products/:slug/demo_license` with `{"email": "buyer@example.com"}`
(publishable `pk_` key auth) returns:
```json
{ "object": "demo_license_request", "status": "sent" }
```
**Always.** Status 202, byte-identical body, for *every* outcome: fresh
issue, idempotent resend, invalid email, missing email, feature disabled,
missing demo plan, unauthorized org plan, rate-limited, host-policy refusal.
The real reason is logged server-side and visible to the seller — never to
the caller.
The reasoning chain an agent should internalize rather than fight:
- **The key is delivered only by email.** That is what makes the captured
email list real — a faked address gets nothing usable.
- Because the response carries no secret and no differentiation, the endpoint
cannot be used to enumerate which emails exist or hold licenses.
- **Resend is idempotent:** an email that already holds an active demo
license for the product gets *the same key re-delivered*, and no second
license or customer row is created. No license farming through re-requests.
- Consequence for client UX: after a 202, the only honest copy is "check
your inbox at {email}" — the client cannot know more, by design. Do not
build UI that promises "key sent!" vs "you already have one"; the contract
forbids distinguishing them.
(Unknown product slugs still 404 like every other product-scoped endpoint —
the neutrality protects *email* state, not product existence.)
### 2.4 Rate-limit budgets
The demo endpoint enforces fixed-window counters:
| Budget | Limit | Notes |
| --- | --- | --- |
| Per IP | 5 / hour | **Refused requests still consume IP budget** (abuse guard) |
| Per email | 3 / hour | Only counted for well-formed emails |
Refusals are externally the same neutral 202 (§2.3). Two operational
consequences:
- **A full funnel test spends the budget.** One end-to-end run of the demo
chain uses most of an IP-hour; plan test runs accordingly (the reference
harness restarts the server — memory cache — between same-hour runs).
- **Verify the limits actually engage.** The reference integration's E2E
caught the rate limiter *failing open* in the real host (a cache store
resolved too early in boot was silently `nil`, and the limiter's no-store
behavior at the time was fail-open — engine 0.9.0 now fails closed when
the cache is absent). No unit test caught it because
tests configured the store explicitly. The lesson generalizes: an agent
should treat "the 4th same-email request is refused, the 6th same-IP
request is refused, and the refused bodies are byte-identical to fulfilled
ones" as a **release-gate assertion** (S11 in §7), not an assumption.
### 2.5 `validate` semantics: 200 does not mean valid
The trap: `POST .../licenses/validate` (license key in the request body —
canonical since 0.9.0; the old `.../licenses/:key/validate` path remains as
a deprecated compatibility route) returns **HTTP 200 for expired,
revoked and suspended licenses**. Validity lives in the body (`valid: false`
plus a reason code such as `expired` or `revoked`). A script that checks only
the HTTP status treats "revoked" as "fine".
The `bin/licenseseat` CLI (§8.1) encodes the distinction in its exit codes,
and an agent driving the API directly should reproduce the same ladder:
| Exit | Meaning | Example |
| --- | --- | --- |
| `0` | License valid | active license, correct fingerprint |
| `5` | **License known but not valid** (`valid: false` on a 200) | expired demo, revoked key |
| `1` | API error | unknown key (the API 404s unknown keys *before* validation — so a bogus key is exit 1, not 5) |
| `3` | Refused client-side before any request | secret-only operation attempted with a `pk_` key |
| `4` | Transport failure | connection dropped mid-response |
Exit 5 is the load-bearing one: it is the difference between "this install
should enter Decay" and "this key does not exist". A useful nuance proven in
the harness: a **hardware-locked license validates as exit 5 before
activation** — not-yet-activated is a *known-but-invalid* state, which is
exactly what a client sees between issue and first activation.
One more payload nuance for decay UX: an **expired demo's** validate payload
still carries `demo: true` and `upgrade_url` even though
`active_entitlements` is empty — precisely the two fields the decay tier
needs to render its upsell.
### 2.6 Key-class ceilings
`pk_` publishable keys are safe to compile into shipped apps and can drive
validate/activate/deactivate/heartbeat/machine-file/demo-request/download-token.
They **cannot hold** license-create or license-revoke scopes — it is a
permissions ceiling, not a granting default. `sk_` secret keys drive
issuance and revocation and must never appear in a client, a repo, a PR
body, or a log. An agent should refuse secret-only operations with a `pk_`
key *before sending the request* — a guaranteed 403 teaches nothing.
---
## 3. Client Integration (Tauri/Rust Reference)
The reference client is Clipbasket: Tauri v2, Rust core, the LicenseSeat
Rust SDK via `tauri-plugin-licenseseat`, pinned to an exact reviewed git
revision. The recipes generalize to any capability-scoped desktop runtime;
the pitfalls are Tauri-specific and expensive.
### 3.1 Plugin config: strict, camelCase, and parsed before your code runs
Tauri strictly deserializes `plugins.` from the effective config for
**every registered plugin before its setup hook runs** — even when the app
passes configuration programmatically (`init_with_config`). The programmatic
values only replace the parsed config *inside* setup; the strict parse
happens first. If the section is absent, `null` is deserialized into a
struct with required fields, and the app **aborts at launch**:
```
PluginInitialization("licenseseat", "Error deserializing 'plugins.licenseseat'
... invalid type: null, expected struct PluginConfig")
```
The block that must exist, with **camelCase keys** (snake_case fails
deserialization with a missing-field error):
```json
"plugins": {
"licenseseat": {
"apiKey": "pk_test_...",
"productSlug": "clipbasket",
"apiBaseUrl": "http://localhost:3990/api/v1"
}
}
```
Use a publishable `pk_` value even here — never a secret — although in an
`init_with_config` app the block's values are placeholders: they must
*parse*, but the compile-time configuration drives the SDK.
### 3.2 Dev runs: the local uncommitted edit (and the overlay that doesn't work)
The committed `tauri.conf.json` deliberately has **no** `plugins.licenseseat`
block (an architecture test locks that in, so a real key can never be
committed by reflex). For development, the block is added as a **local,
uncommitted edit** to `src-tauri/tauri.conf.json`.
**The pitfall, observed directly:** `pnpm tauri dev -- --config `
does **not** get overlay content into plugin config — a JSON overlay attempt
died with a TOML-parse error, and a TOML overlay was silently not merged.
The local edit to `tauri.conf.json` itself is the only path that reached the
plugin in dev. Budget for this: keep the edit out of commits by staging
specific files, never `git add -A` over that file, and end every session
with `git diff src-tauri/tauri.conf.json` showing only that block.
Compile-time values come from env vars read via `option_env!`
(`CLIPBASKET_LICENSESEAT_API_KEY`, `..._API_BASE_URL`, the signing public
key and key id) — changing them triggers a rebuild via
`rerun-if-env-changed`. Debug builds accept plain-HTTP localhost API URLs;
release guards enforce bounded HTTPS.
### 3.3 Release builds: script injection + the offline boot test
Because the committed config has no plugin block and dev relies on an
uncommitted edit, **a release built from a clean checkout is a launch-abort
landmine** — the exact failure of §3.1, discovered as a launch blocker in
the reference integration and fixed as
[clipbasket #8](https://github.com/rameerez/clipbasket/pull/8).
The release recipe:
1. **The release script injects the block** into the same `--config` overlay
that already carries the updater public configuration (`tauri build
--config` overlays *do* merge, unlike dev's). CamelCase keys; no
`apiBaseUrl` entry so the compiled production default applies.
2. **A side-effect-free `--print-config` mode** validates the public release
configuration and prints the exact overlay JSON, exiting before any
signing, build, or upload — so a test can exercise the real script.
3. **An offline boot test red-proofs the landmine.** The test merges the
printed overlay over the committed config using the same RFC 7396 merge
semantics Tauri uses, extracts `plugins.licenseseat` exactly like
`tauri::plugin` does, and deserializes it into the **real pinned plugin's
`PluginConfig`** — the precise operation that aborts a packaged app.
Written before the fix, it failed with the exact production panic; after
the fix it passes, and it also asserts that the committed config *still*
fails (documenting the landmine) and that snake_case keys fail (locking
the serde contract).
The general pattern is the point: **when a failure mode only exists in a
packaged build, reproduce its exact mechanism as an offline test** —
merged-config deserialization against the real struct — instead of hoping a
human remembers to launch a staging build. (Still do launch the signed
staging build once per release; the offline test covers the config
mechanism, not code-signing or notarization.)
### 3.4 Profile isolation: your data-dir override does not cover the SDK
Debug builds honor a data-dir override (`CLIPBASKET_DATA_DIR=/tmp/clipbasket-dev`)
so dev runs never touch the production profile. Two pitfalls inside that
sentence, both hit:
- **The SDK resolves its own storage path** (platform app-data dir) and
knows nothing about your override. Without explicitly deriving the SDK's
`storage_path` from the same override, dev license state lands in the
production profile: wiping the dev dir no longer resets to the Wall, and a
dev activation can disturb the license state of the app the developer uses
daily. Pass the SDK a storage path under the override in debug; leave it
default in release.
- **The single-instance guard is keyed on the bundle identifier**, which dev
and production share — a running production app silently swallows the dev
launch. Quit the real app first.
The isolated-run one-liner that results:
```bash
CLIPBASKET_DATA_DIR=/tmp/clipbasket-dev pnpm tauri dev
```
Unconfigured debug builds (no compiled key) deliberately run fail-closed:
`unconfigured` state, no clipboard handle, no activation input, and an
isolated cache prefix so they can never read or submit a real cached
license.
### 3.5 The tier engine: one snapshot, one projection
The client core that makes §1 real, as shipped in
[clipbasket #7](https://github.com/rameerez/clipbasket/pull/7):
- **One resolution function** maps a coherent SDK snapshot to
`ProductTier { Entitled, Decay, Wall, Locked }`: authoritative grant →
Entitled; no grant but a license was seen on this install → Decay; no
grant and no license ever → Wall; unconfigured build → Locked.
- **A restore-in-progress is not a verdict, so nothing may act on it.**
(This supersedes an earlier draft of this section, which said restore ran
as Decay. [clipbasket #11](https://github.com/rameerez/clipbasket/pull/11)
proved that wrong in both directions: a licensed user reaching for the
shortcut during the sub-second restore is met by the paywall, and the
reconciliation tears down the popup it is about to need.) Restoring is
**undecided** — a distinct predicate from any tier — and every seam that
could act on a tier has to agree to sit still until the SDK decides: the
window coordinator applies nothing, and the activation-surface presenter
returns without presenting, because the coordinator will present the right
surface a moment later once the restore resolves.
- **Diff against what was last APPLIED, not against the last state
observed.** This is the non-obvious half, and skipping it silently
reintroduces the bug: if a skipped restore is recorded as the new
baseline, it swallows the very transition it was hiding. Entitled →
restore → Decay then leaves a live popup in Decay, because the restore
already looked like the destination. One pure function
(`applied`, `next`, `undecided`) → `Option` makes the whole
sequence testable without a running app; the state machine is where the
bug lives, not the window code.
- **Feature gates read `active_entitlements` from the snapshot** — only the
Entitled tier can grant, and only keys the plan actually carries. A
granted license whose plan carries no feature keys runs at decay caps: the
client never invents entitlement names or infers power from a plan label.
(Operational consequence for sellers: **configure the production plans'
entitlements before shipping the client** — this is fail-closed by design,
and an entitlement-less paid plan would run paying customers at decay
caps.)
- **Caps are one pure function**: history = min(user setting,
`unlimited_history.metadata.max_clips`); pins/saved from their
entitlements' `metadata.limit` (−1 = unlimited); Decay/Wall/Locked →
the compiled fallback constants (5 / 0 / 1). Enforced at admission and
read sites — never as a downgrade-edge prune (§1.3). (Since
[clipbasket #9](https://github.com/rameerez/clipbasket/pull/9) the Decay
tier no longer renders the popup at all — §1.2 — so the decay constants
are the engine's fail-closed floor, not a user-visible mode.)
- **The UI gets a bounded scalar projection**, not the SDK state: tier
booleans, a redacted key hint (§4.4), timestamps, entitlement keys with
small scalar metadata, a validated HTTPS `upgrade_url`. Never the full
key, fingerprint, activation id, or raw API responses. License-mutating
commands stay confined to the settings and license windows (§3.8 pins
this); the popup gets a read-only projection; the runtime re-checks tier
natively at every commit/export boundary (the renderer is presentation,
not policy).
- **Offline parity comes free:** machine files carry `active_entitlements`
(with `expires_at`) too, so the tier engine and the demo countdown work
identically from a signed offline artifact — no special offline tier
logic. The demo badge countdown renders from the license's own expiry,
known locally.
### 3.6 The state event beats the command response
Symptom: "I activate and the window just disappears" — or a redirect fires
before the activation confirmation ever renders.
The SDK emits its license-state change event as soon as activation commits,
and that event can reach the UI **before** the `activate()` call itself
resolves. Any window with an "auto-close when access is granted" rule — a
good rule, because it handles activation happening from *another* window,
or a restore landing — will fire that rule during its own activation and
close the window out from under the confirmation it was about to show.
Fix: claim the outcome *before* awaiting, not after:
```ts
ownsActivationRef.current = true; // BEFORE await
try {
const next = await activate(key);
if (next.accessAllowed) { setConfirmed(next); return; }
ownsActivationRef.current = false; // failed: release the claim
} catch { ownsActivationRef.current = false; }
```
The auto-close rule then ignores events it caused and still closes for
events it did not. **Pin it with a test that reproduces the real ordering**
— emit the state event *inside* the mocked command, before it resolves —
and red-proof it (§3.9): the reference fix was verified by reverting it and
watching the test fail
([clipbasket #9](https://github.com/rameerez/clipbasket/pull/9)).
### 3.7 A menubar app must outlive its windows
Desktop frameworks commonly exit when the last window closes. A tray app
never notices — until some tier stops keeping a window alive. In the
reference integration the bug arrived exactly when decay went hard-paywall
(§1.2): the popup was torn down in Decay, the license window became the
only window, and closing it ended the process. Users report this as "the
app crashed."
Fix: intercept exit and honor only deliberate ones:
```rust
.run(|_app, event| {
if let RunEvent::ExitRequested { code, api, .. } = event && code.is_none() {
api.prevent_exit(); // a window closed; the tray keeps the app alive
}
});
```
An explicit `app.exit(0)` carries a code and passes through, so the tray's
Quit item still works. This interception is also what makes the no-exit
paywall honest (§4.3): closing the paywall leaves the app resident and
capturing.
### 3.8 A granted capability still fails until the native guard agrees
Symptom: `Window 'x' is not allowed to call this command` — *after* you
already granted that window the command in the capability JSON.
In Tauri (and any capability-scoped runtime), the capability grant is only
half the gate. If the command also re-checks the calling window natively —
which it should: the renderer is presentation, not policy (§3.5) — then the
permission system and the native guard **must both agree** before the call
succeeds:
```rust
// capability JSON says the license window MAY call this…
#[tauri::command]
pub fn activate_product_license(window: Window, ...) -> Result<...> {
// …and this must agree, or it fails anyway.
ensure_window_allowed(&window, &[SETTINGS_LABEL, LICENSE_LABEL])?;
```
Guardrail: a source-reading architecture test that asserts the two layers
match (§3.9). Crude and effective — it turns "fails in a user's hands" into
"fails in CI" when an agent adds a window or a command and updates only one
layer ([clipbasket #9](https://github.com/rameerez/clipbasket/pull/9)).
### 3.9 Agent working style that made this integration reviewable
- **Pin the SDK to an exact reviewed revision** (git `rev`, later a released
version). Reproducible builds; no unreviewed branch drift enters a
release.
- **Source-reading architecture tests** as invariants an agent cannot
silently break: the capability/guard agreement of §3.8; the committed
config must have no plugin block; seat-changing commands stay confined to
their allowed windows.
- **Red-proof every regression test**: mutate the fix out, record the
failure, restore. In the tier-engine PR, two first-draft tests *survived*
their mutations (realistic snapshots carried no stale entitlements) and
were strengthened with adversarial cases — the discipline catches weak
tests, not just missing ones.
---
## 4. The Licensing UX Layer
The funnel of §1 is won or lost in a handful of client UX decisions. These
are the ones that shipped in the reference integration
([clipbasket #9](https://github.com/rameerez/clipbasket/pull/9)), as
recipes.
### 4.1 Upgrading must never pass through deactivation
The single highest-value recipe in this layer.
The naive upgrade flow — user buys, deactivates the demo, activates the
paid key — surrenders the seat *first*. A typo in the new key then drops a
paying customer into decay (§1.2: a hard paywall), seconds after they paid.
It is the worst possible moment for the worst possible experience.
Instead, verify two properties of your SDK, then let users paste the paid
key **straight over the running demo**:
1. **Activation replaces in place.** A successful `activate()` commits the
new license and swaps the runtime state. Capture never stops.
2. **Activation failure is inert.** A rejected key emits an error and
leaves the existing license untouched.
Both hold in the LicenseSeat SDKs, and the reference client pins both
outcomes with tests: upgrade-over-demo swaps without a deactivation, and a
rejected key leaves the demo running. The upgrade UI is then simply a key
box visible *during* the demo, labeled for what it is:
> **Already bought a license? Paste your key**
> No data is lost, and you keep using the app as normal.
The old seat on the demo license stays activated server-side. That is fine
— it is a different license and it expires on its own. If it bothers you,
reap it in a background job; never make the user's upgrade depend on it.
### 4.2 Capture and surface are separate gates
Whether the app keeps *recording* is one question; whether the user can
*see and use it* is another. Conflating them produces either a paywall that
quietly destroys value ("nothing was saved while you were lapsed") or a
trial that never converts ("the free version is good enough").
The reference client keeps them as two named predicates over the tier:
`clipboard_runtime_allowed_for_tier` (Entitled + Decay) governs capture,
and `popup_surface_allowed_for_tier` (Entitled only) governs the UI. That
pairing is what makes the §1.2 decay design work: a lapsed user's history
keeps filling up behind the paywall, so buying restores a *fuller* history
than the demo ended with — a conversion argument and a data-safety
guarantee in the same mechanism.
### 4.3 Give the paywall no exit
If the app is unusable without a license, a "Not now" button is a lie — it
leads to an inert tray icon. The license window offers only routes that
resolve the state (request a demo key, activate a key, buy) and no decline
affordance.
Closing the window from the titlebar is still allowed — and leaves the app
**resident and still capturing** (§4.2, §3.7), so the next shortcut press
re-presents the paywall. That is the inventory argument: the paywall needs
no exit because the app never really leaves, and every muscle-memory
shortcut press is another visit to the sales surface.
### 4.4 Redact the key, keep the brand
`•••• 1234` tells the user nothing about *which* license is installed. If
keys carry a branded prefix, keep the first group and the last four and
mask the middle:
```
DEMO-••••-••••-1234
```
Guard the branded form: use it only when the leading group is short enough
to be a brand (≤8 chars), there are at least three groups, and enough of
the key stays masked — otherwise fall back to tail-only, or a short or
odd-shaped key reveals more than intended. And the dependency runs
server-side: whether keys are branded at all is a **key-generation-time**
choice the client can only reflect.
### 4.5 Copy rules: the tier vocabulary is a SSOT
- **Two tier words, never conflated.** **PRO** names what the paid license
grants — the tier the PRO-labeled pills advertise. **DEMO** names how
long you have it free: the countdown pill reads `DEMO — 14 days
remaining`. One word for both makes "PRO — free for 14 more days" read
as a permanent state.
- **Define every user-facing licensing string once** — tier name, price,
buy labels, gate messages, countdown format, site URL — and test that
every surface builds from the definitions:
```ts
expect(BUY_LABEL).toContain(PRICE_LABEL);
expect(proFeatureTooltip("Copy Markdown")).toBe(`Copy Markdown — ${PRO_TIER_NAME}`);
```
- **Never hardcode the payment URL in the client.** It arrives as license
metadata (`upgrade_url`, §2.2) with a marketing-site fallback — changing
where people pay must not require a desktop release.
- **Say what changed, not what happened internally.** "Your demo is now a
full license," not "License activated." And reassure in one sentence — a
paragraph about coverage gaps creates the fear it is trying to prevent.
- **Never promise behavior the default install does not have**, and when
you delete an affordance, grep the copy for its name — licensing copy
outlives controls, and a message pointing at a deleted button sends users
hunting for it.
### 4.6 The empty state is onboarding
"Your clipboard is empty" is a status report. Three lines — what fills it,
how to summon it again, how to search it — turn the same pixels into a
tutorial that costs nothing. The reference popup's teaching empty state is
test-pinned, including a test that it never promises an interaction the
default install cannot deliver (a paste-on-Enter tip that is opt-in *and*
needs an OS permission would simply not happen on a fresh install).
---
## 5. Distribution
Licensing without distribution is half a product: the same server that knows
whether a license is valid should decide who gets which binary and which
update. This section is the Distribution v2 surface
([license_seat #10](https://github.com/rameerez/license_seat/pull/10), host
wiring in [licenseseat #33](https://github.com/rameerez/licenseseat/pull/33)),
as proven end to end by the harness in §7.
### 5.1 Releases and artifacts: the publish pipeline
A **Release** (product + version + channel) with **ReleaseArtifacts** (one
per platform/arch file) is the single source of truth; every update feed is
a serializer over it. The agent-drivable publish pipeline, pure public API
with an `sk_` key:
1. **Create** a draft release (`version`, `channel`, notes).
2. **Register** an artifact (filename, platform/arch, declared digest) →
the response carries direct-upload instructions.
3. **Upload** the bytes (presigned PUT).
4. **Finalize** → the server re-computes the digest by streaming (must match
the declared one) and applies managed signatures.
5. **Publish** → the release goes live in every feed at once.
Two sharp edges measured in the harness: artifact filenames are unique per
release — re-registering the same filename (or re-creating the same version)
surfaced as a 500 in earlier engines; 0.9.0 renders a clean **409
`conflict`**. Still make publish scripts idempotent by *checking before
creating*, and use a fresh version per test run.
### 5.2 Signing keys: custody, or byte-identical interop
Per-product **SigningKeys** hold Sparkle Ed25519 seeds and Tauri minisign
keypairs, encrypted at rest, with generate / import / retire lifecycles —
imports accept the real tools' formats (Sparkle `generate_keys -x` seeds;
minisign / rsign2 / `tauri signer` documents, including encrypted ones). The
trust claims are anchored to the real consumers, not to a reimplementation's
opinion: server-produced Sparkle signatures are **byte-identical to
`sign_update` output** (golden fixture in CI), Tauri feeds verify against
the actual updater plugin's deserializer + `minisign-verify`, and
electron-updater channel files round-trip through real electron-updater
provider code.
Client-side custody rule (from the reference integration's release
checklist): the app pins the signing **public** key + key id at compile
time; rotating the key means shipping a new build *while the old trust path
still works* — never rotate server-side first and strand installed clients.
### 5.3 Update feeds: one upload, every updater
Published releases are served simultaneously as:
| Feed | Endpoint | Notes |
| --- | --- | --- |
| Sparkle appcast | `/p/:slug/appcast.xml` | channels, phased rollout, critical updates, deltas, `edSignature` |
| Tauri static | `/p/:slug/tauri/latest.json` | `{os}-{arch}` keys; universal macOS artifacts published under both arches |
| Tauri dynamic | `/p/:slug/tauri/:target/:arch/:current_version` | `200 {version,url,signature,notes,pub_date}` when an update exists; **`204` = up to date** |
| electron-updater | `/p/:slug/electron/latest[-mac\|-linux].yml` | plus relative artifact/blockmap serving |
| Generic | `/p/:slug/latest?channel=...` | JSON with artifact `url` + `sha512` for custom in-app checks |
The dynamic-endpoint contract matters for clients: **204 means "no update",
including when the feed policy gates you out** — a gated updater sees a
quiet no-op, never a scary 4xx.
### 5.4 Download tokens: license-gated bytes
```
POST /api/v1/products/:slug/releases/:version/download_token (pk_ + license_key)
→ { "token": "...", "expires_at": "<+5 minutes>" }
GET /d/:artifact_id/:filename?token=...
→ 302 to signed storage URL → bytes
```
Gating semantics proven in the harness: the license must carry the
`download_artifacts` entitlement; with `update_feed_policy: "licensed"` a
tokenless download is 403; after a revocation, token *minting* fails 422
`license_invalid` and an already-gated download with the revoked key is 403.
Tokens are deliberately short-lived (~5 minutes): mint at click time, never
embed in emails or pages.
### 5.5 "One year of updates": the version-cutoff model
The paid model that closes the loop — perpetual license + 12 months of
updates (the Sketch model), enforceable because feeds and downloads are
license-aware:
- **Plan shape:** the paid plan carries all feature entitlements as
`perpetual`, **plus** an `updates` entitlement with
`duration_type: fixed`, 12 months. The app works forever; only update
access lapses. The client already knows its window offline
(`active_entitlements[].expires_at` ships in validate responses *and*
machine files) and can render "renew" natively with zero extra requests.
- **Version-cutoff semantics (live today):** the distribution gate
compares each release's `published_at` against the license's `updates`
window. Releases published **inside** the window stay downloadable forever
— you keep your year's versions, including re-downloads and reinstalls.
Releases published **after** the window are refused with a distinguishable
403 code (`version_not_entitled` / `not_entitled`, not a bare 403) so
clients can render "renew" instead of erroring. Feed reads keep serving a
lapsed license its **last entitled version** rather than an error.
- **Launch posture:** ship with `update_feed_policy: open` while the entire
first cohort is inside its paid window anyway; flip entitlement-gated
enforcement on before the first cohort's window closes (~month 11). Gating
machinery first, policy flip later — the flip is config, not a release.
---
## 6. Email and Demo Capture
### 6.1 The demo plan setup recipe (canonical shapes)
This is the exact server-side configuration the reference integration seeds
and the E2E harness (§7, S11) verifies — use it as the template:
**Demo plan** (`demo-14d`): `duration_days: 14`, `seat_limit: 1`,
entitlements = *every* feature key the product gates, caps as top-level
fields (§2.1):
```json
[
{ "key": "ocr" },
{ "key": "markdown_copy" },
{ "key": "file_path_variants" },
{ "key": "link_previews" },
{ "key": "unlimited_history", "max_clips": 1000 },
{ "key": "saved_items", "limit": -1 },
{ "key": "pins", "limit": 3 }
]
```
The 14-day clock is the **license's own expiry** (`duration_days`), not the
entitlements' — the entitlements simply vanish with the license's validity,
which is what makes "full power, then decay" one mechanism.
**Paid plan** (`pro-lifetime`): no `duration_days` (perpetual), the same
feature keys as `perpetual` entitlements, plus
`{ "key": "updates", "duration_type": "fixed", ... 12 months }` (§5.5).
**Product configuration** (dashboard, "Demo licenses" card): enable toggle ·
demo-plan picker · `upgrade_url` (the product's payment link — this feeds
the §2.2 enrichment/mirror) · `demo_gate` posture flag (§1.4). Demo licenses
are a paid-platform feature (org plan gating), and the demo email delivers
regardless of the org's customer-email toggle — it is the feature's delivery
channel, not a courtesy copy.
**Conversion is free:** the purchase webhook's find-or-create-by-email lands
the paid license on the **same Customer** as the demo license, the product's
demo-conversion counter increments, and the paid validate payload carries no
demo enrichment. The seller's remarketing list is the Customers page
filtered to the demo plan; export is the ESP boundary (LicenseSeat is not an
email marketing tool).
### 6.2 Email quality: spend email only where it can land
Because the demo key travels *only* by email, an undeliverable address makes
the whole issuance pointless — and disposable inboxes are the
license-farming vector. The platform runs a **layered, fail-open verdict**
on every address it spends email (or a license) on
([licenseseat #49](https://github.com/rameerez/licenseseat/pull/49)):
1. **format** — can this shape be transported at all;
2. **domain resolves** — DNS MX-then-A with bounded timeout, cached
(positive 1h, negative 24h);
3. **disposable** — throwaway-inbox detection
([nondisposable](https://github.com/rameerez/nondisposable) 0.3.0:
bundled seed list so a fresh install isn't blind, registrable-domain
matching so `x.tempmail.example` can't evade a `tempmail.example` entry,
configurable failure mode);
4. **suppressed** — did this address already hard-bounce or complain
(§6.3).
Policy is per surface, and the demo endpoint's refusal rides the same
neutral 202 (§2.3) — quality gating never becomes a new enumeration oracle.
Webhook-sourced purchases are **never blocked**: a paying customer with a
disposable email is still a paying customer; the verdict is stored as
advisory flags on the Customer instead. And the whole stack **fails open**:
a DNS outage or a broken table degrades to "send it" with a loud log — only
definitive negatives ever refuse.
### 6.3 The suppression loop: protect the sender reputation you share
Bounces and complaints feed back automatically
([licenseseat #48](https://github.com/rameerez/licenseseat/pull/48)):
SES → SNS → webhook (signature-verified, auto-confirming) → a suppression
table → an app-wide mailer interceptor that silently drops suppressed
recipients from every future email. Permanent bounces and complaints
suppress immediately; transient bounces suppress after 3 within 30 days.
Proven in production with the SES mailbox simulator: a simulated hard bounce
became a suppression row in 25 seconds. Sellers get this for free — it is
platform behavior, not per-product setup.
### 6.4 Sellers configure; agents automate
The honest division of labor for the capture funnel:
| Sellers configure (dashboard / accounts they own) | Agents automate |
| --- | --- |
| Product + plans + the demo card (§6.1) | Client wall/demo/decay tiers (§3) |
| The payment link (Stripe et al.) and its product mapping | The in-app demo request (native POST with the compiled `pk_`) |
| `upgrade_url`, `demo_gate`, feed policy | The full verification chain (§7) |
| Whitelabel email branding | Release publishing (§5.1) via `sk_` in CI |
One current boundary worth knowing: browser-side calls to the demo endpoint
from a static site are blocked (no CORS on the API yet — a known, tracked
item), so Flow B on a static site means either the in-app wall (the
reference choice), a server-side form handler, or the hosted demo-request
page when it ships.
---
## 7. The Verification Kit
The reference integration's release gate is an **E2E harness** — eleven
scripted checkpoints (S1–S11) that drive the entire surface of this playbook
against a locally-booted server with the real gem, real crypto, and a real
mail catcher. It exists because unit suites kept passing while three
different launch-relevant paths were dead (an unwired signing provider, a
silently lossy entitlement shape, a fail-open rate limiter — all found by
the harness, all fixed and red-proofed). An agent should run — or rebuild —
this chain before calling any integration release-ready.
| Checkpoint | Proves | The expectations that catch real bugs |
| --- | --- | --- |
| **S1** health | server up | `/api/v1/health` 200 |
| **S2** seed | org/product/plans exist | plans carry §6.1 entitlement shapes (top-level cap fields) |
| **S3** keys | key model | `pk_`/`sk_` minted; raw values only at creation |
| **S4** issue | issuance path | `license-create` with `sk_` → 201 + key |
| **S5** lifecycle | seat + validate semantics | pre-activation validate **exit 5** (known-but-invalid); activate → exit 0; heartbeat lands server-side; deactivate; seat count 0→1→0; **bogus key exits 1, not 5** |
| **S6** offline | machine-file trust chain | Ed25519 signature verifies fully offline with only the fetched public key; a tampered file is rejected (negative control required) |
| **S7** publish | the §5.1 pipeline | create → register → upload → finalize (server digest matches declared) → publish; managed signatures applied |
| **S8** feeds | every updater format | appcast well-formed with `edSignature`; Tauri static carries universal under both arches; dynamic returns 200 with update, **204 when current**; generic latest carries `url` + `sha512` |
| **S9** download | gated bytes | token (pk_ + license) → 302 → downloaded bytes **byte-identical** to upload |
| **S10** revocation | gating end-to-end | `update_feed_policy: licensed` → tokenless 403; after revoke: validate **exit 5**, token mint 422 `license_invalid`, gated download 403 |
| **S11** demo chain | the whole §2/§6 contract | request → neutral 202; **key extracted from the delivered email** drives the rest (proves the email carries a working key); activate shows `demo: true`; validate carries all demo entitlements **single-nested** (assert no `metadata.metadata`) + the `license.metadata` mirror; re-request → same 202, **same key**, still one license/customer; 4th same-email and 6th same-IP requests refused with **byte-identical** bodies; demo window exactly 14 days; expired validate → exit 5, `code: "expired"`, still `demo: true` + `upgrade_url`, empty entitlements; paid issue on the same customer → conversion counter increments, no demo enrichment |
Three habits that made the harness worth more than its runtime:
- **Chain from real artifacts, not fixtures.** S11 parses the demo key out of
the actual delivered email body; S9 compares actual downloaded bytes. If
the email template breaks or storage serves the wrong file, the chain
fails even though every unit test passes.
- **Negative controls are mandatory.** The tampered machine file (S6), the
tokenless download (S10), the bogus key (S5) — a gate that has never been
seen refusing is not a gate.
- **Budget for the rate limiter** (§2.4): one full S11 spends the IP-hour;
restart between same-hour runs.
---
## 8. Ops for Agents
### 8.1 `bin/licenseseat`: the CLI an agent drives
The platform ships a stdlib-only CLI over the public API
([licenseseat #36](https://github.com/rameerez/licenseseat/pull/36)) —
15 API subcommands plus an `mcp` server mode:
```
health auth-test plans signing-key validate activate deactivate
machine-file heartbeat license-create license-revoke releases latest
download-token demo-license mcp
```
Design decisions that exist specifically because agents (and shell scripts)
are the callers:
- **One JSON envelope on STDOUT, always** — human commentary goes to STDERR.
All three of the API's error-envelope shapes (nested engine errors, flat
auth errors, JSON:API machine-file errors) are normalized into one
`error.code` / `error.message`.
- **The exit-code ladder of §2.5**, including exit 5 for `valid: false` —
the 200-but-revoked trap closed at the tool layer.
- **Secret-only operations are refused client-side with a `pk_` key**
(exit 3, zero bytes sent) with a sentence explaining the key model,
instead of a spent round trip and a bare 403.
- **The key comes from the environment only** (`LICENSESEAT_API_KEY`) — no
`--api-key` flag, so keys cannot land in shell history or `ps` output.
- Every `--help` states *when* to call the command, which key class and
scope it needs, and its exit codes — the six things integrators
consistently get wrong (offline is activate-*then*-machine-file;
`machine-file` is hyphenated; test keys are rejected in production; …)
are written where the failure happens.
### 8.2 The MCP server: the same surface for model callers
`bin/licenseseat mcp` serves 14 of those operations as MCP tools (no demo
tool; spec
revision 2025-06-18, stdio): protocol errors vs in-band `isError` execution
failures split per spec, mutating tools declared via `ToolAnnotations`
(`destructiveHint`) so a host can gate them mechanically, one JSON object
per line and nothing else on STDOUT, and the API key masked everywhere
including the startup banner. An agent host connects it with an env-scoped
key and gets the full §2 contract — exit-code semantics included in the
envelopes — without shell plumbing.
### 8.3 Cloud configuration as idempotent scripts
The pattern to copy for any cloud-side setup an integration needs, as
exemplified by the platform's SES feedback wiring (`bin/ses-setup`,
[licenseseat #48](https://github.com/rameerez/licenseseat/pull/48)): no
console clicking — a script that (a) **reads current state first and skips
every mutation that already matches** (safe to re-run, always), (b) offers
`--dry-run` printing the exact mutating commands instead of executing them,
(c) encodes ordering constraints that are otherwise tribal knowledge (SES
refuses to disable feedback forwarding until both bounce and complaint
topics are set — the script does topics first, forwarding last), and
(d) separates the phases that can run any time from the one that needs the
deployed endpoint live (SNS subscription confirmation). The production
rollout of that script — prepare, deploy, subscribe, then a
simulator-verified bounce-to-suppression-row in 25 seconds — was executed
agent-side end to end, human-authorized.
### 8.4 What still needs a human — the honest boundary
An agent can execute everything in §§2–7 autonomously. As of today, these
remain human taps, and pretending otherwise produces stuck automation:
| Human-only step | Why |
| --- | --- |
| **Creating the product, plans, and API keys** on the LicenseSeat dashboard | No management REST API exists (deliberate: the blast radius of an `sk_` that can create products is a real product decision, not an oversight). The 15-minute path starts with two dashboard steps; everything after is agent-drivable. |
| **Stripe (or other PSP) payment links** and their account | The seller's money lands in the seller's PSP account; creating links and connecting the account is theirs. Once the link exists, mapping → issuance → email is automatic. |
| **Apple Developer ID certificates, code signing, notarization** (and their Windows equivalents) | Certificates are bound to the seller's developer account and hardware keychain. The release *script* is agent-runnable; the credentials are not agent-mintable. |
| **Package-registry publishes behind MFA** (RubyGems, npm, crates.io…) | MFA is the point. Agents prepare the artifact and the exact command; the human runs it (or explicitly delegates a scoped token). |
| **Production deploy authorization** | Policy, not capability — deploys in the reference project are agent-executed but human-authorized, per session. |
Treat this table as a checklist to *clear early*: an agent that requests the
dashboard seed, the payment link, and the signing certificate on day one is
never blocked on them on ship day.
---
## Appendix: The Reference Implementation's Receipts
Every recipe above traces to reviewed, merged code. The trail, for auditors
(several repos are private; the links are provenance for the team rather
than public reading):
| Claim area | Where it landed |
| --- | --- |
| Demo endpoint, neutral 202, rate limits, plan validation, payload enrichment (engine 0.7.0) | [license_seat #11](https://github.com/rameerez/license_seat/pull/11) |
| Demo product card, plan gating, customer-aware issuance, metadata mirror (host) | [licenseseat #44](https://github.com/rameerez/licenseseat/pull/44) |
| Demo-licenses PRD (funnel, contracts, §4.5 tier inventory, Phase-2 renewals) | [licenseseat #43](https://github.com/rameerez/licenseseat/pull/43) |
| Distribution v2 engine: releases/artifacts/signing keys/feeds, byte-level interop proofs | [license_seat #9](https://github.com/rameerez/license_seat/pull/9), [#10](https://github.com/rameerez/license_seat/pull/10) |
| dv2 host wiring + PRD + proof scripts | [licenseseat #33](https://github.com/rameerez/licenseseat/pull/33) |
| Download-token signing provider fix (found by the harness: endpoint dead, suite green) | [licenseseat #42](https://github.com/rameerez/licenseseat/pull/42) |
| CLI + MCP server + skill | [licenseseat #36](https://github.com/rameerez/licenseseat/pull/36) |
| Email quality layers + policy + security fixes | [licenseseat #49](https://github.com/rameerez/licenseseat/pull/49) |
| SES suppression loop + `bin/ses-setup` | [licenseseat #48](https://github.com/rameerez/licenseseat/pull/48) |
| Disposable-email hardening (0.3.0) | [nondisposable #3](https://github.com/rameerez/nondisposable/pull/3) |
| Client: production licensing integration + release hardening | [clipbasket #1](https://github.com/rameerez/clipbasket/pull/1), [#2](https://github.com/rameerez/clipbasket/pull/2) |
| Client: tier engine, caps, gates, popup lifecycle | [clipbasket #7](https://github.com/rameerez/clipbasket/pull/7) |
| Client: release-config injection + offline boot test | [clipbasket #8](https://github.com/rameerez/clipbasket/pull/8) |
| Client: license-window UX — hard-paywall decay (resident + capturing, §1.2), one paywall surface with no exit (§4.3), upgrade-over-demo without deactivation (§4.1), activation-race fix (§3.6), `ExitRequested` interception (§3.7), capability/guard pinning (§3.8), SSOT tier vocabulary + DEMO countdown copy (§4.5), branded key redaction (§4.4), teaching empty state (§4.6) | [clipbasket #9](https://github.com/rameerez/clipbasket/pull/9) |
| PRD amendment: decay = resident-capturing hard paywall (adjudicated to match clipbasket #9) | [licenseseat #51](https://github.com/rameerez/licenseseat/pull/51) |
| Concealed-clipboard privacy (never paywall privacy) | [clipbasket #6](https://github.com/rameerez/clipbasket/pull/6) |
## Next Steps
- [[guides/offline-licensing|Offline Licensing]] — machine files in depth
- [[entitlements|Entitlements]] — the entitlement model reference
- [[api-reference/download-token|Download Tokens]] and
[[api-reference/signing-keys|Signing Keys]] — the distribution API surface
---
## Air-Gapped Licensing
# Air-Gapped Licensing
For devices that never connect to the internet, LicenseSeat currently supports an **operator-assisted machine-file workflow**:
1. Collect the target machine fingerprint on the air-gapped device.
2. Use a connected admin/service machine to call `activate` for that fingerprint.
3. Call `machine-file` for the same fingerprint.
4. Transfer the resulting machine file back via USB or other approved media.
5. Verify it locally on the air-gapped device.
This works today. It consumes a seat correctly, preserves device binding, and does not require a dedicated challenge-response protocol.
> **Important:** LicenseSeat does **not** currently ship a first-class challenge-response product flow, QR ceremony, offline-activation endpoint, or dashboard wizard for air-gapped provisioning. If you need those, build the current API-assisted workflow first and treat challenge-response as future product work.
## Current Supported Workflow
### Step 1: Collect the fingerprint on the target machine
Your app, helper CLI, or provisioning tool should print or export the machine fingerprint from the target device.
Examples:
- A small helper app that displays the fingerprint on screen
- A CLI that writes the fingerprint to a text file for USB transfer
- A local admin page that exports fingerprint + optional device name
### Step 2: Activate from a connected machine
Activation is what consumes the seat.
```bash
curl -X POST "https://licenseseat.com/api/v1/products/my-app/licenses/activate" \
-H "Authorization: Bearer pk_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"license_key": "LICENSE-KEY",
"fingerprint": "target-machine-fingerprint",
"device_name": "Factory PLC 07"
}'
```
### Step 3: Issue the machine file
Machine-file issuance does not consume an extra seat. It requires the activation from the previous step to already exist.
```bash
curl -X POST "https://licenseseat.com/api/v1/products/my-app/licenses/machine-file" \
-H "Authorization: Bearer pk_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"license_key": "LICENSE-KEY",
"fingerprint": "target-machine-fingerprint",
"ttl": 365,
"include": ["license"]
}'
```
The response contains the PEM-like machine-file certificate. Save it to disk and transfer it to the target system.
### Step 4: Import or place the machine file on the target machine
What this looks like depends on your SDK/app:
- Newer SDKs such as C++ and Rust/Tauri can verify the machine file directly.
- Older SDKs may still use signed offline tokens instead of machine files and have not migrated yet.
- A custom integration can store the certificate in your own secure local storage and verify it on startup.
## Why this is secure
- **Seat consumption happens first**: you cannot mint unlimited offline credentials without activating.
- **Fingerprint binding is preserved**: the machine file is encrypted using the license key and target fingerprint.
- **Tamper detection is built in**: the machine file is signed with Ed25519.
- **Machine files stay finite**: use longer TTLs for true air-gapped systems, but not infinite ones.
## Choosing TTLs for air-gapped systems
Recommended starting points:
| Environment | Suggested TTL | Suggested grace period |
|-------------|---------------|------------------------|
| Consumer / laptops | 30 days | 3-7 days |
| Professional field equipment | 90-365 days | 7-30 days |
| Strictly air-gapped industrial systems | 365-3650 days | 7-30 days |
Long TTLs are a tradeoff:
- better operational convenience
- slower revocation visibility
- slower entitlement/metadata change visibility
The underlying license expiry still wins. A machine file does not outlive the license itself.
## What is not productized yet
These are sensible future improvements, but they are **not** current built-in features:
- Challenge-response encoded blobs
- QR-based offline activation/import ceremonies
- One-click dashboard action: “paste fingerprint → activate → download machine file”
- Batch renewal packages from the dashboard
- Dedicated offline-activation endpoints separate from `activate` + `machine-file`
## Best Practices
1. Keep a documented record of which physical device maps to which fingerprint.
2. Use a friendly `device_name` when activating so audit logs stay readable.
3. Prefer machine files over legacy offline tokens for new air-gapped deployments.
4. Choose a TTL intentionally instead of asking for “never expires”.
5. Test the full USB/manual provisioning workflow before shipping to a remote site.
---
## Offline Licensing
# Offline Licensing
LicenseSeat provides robust offline licensing that lets your users work without an internet connection while maintaining security and preventing license abuse.
Whether your user is on a 14-hour flight, touring China for a month with unreliable internet, or operating equipment in a remote location—their software keeps working.
> **Current offline model:** machine files are the preferred offline artifact. They are encrypted, activation-bound, and fingerprint-bound. Older SDKs may still use signed offline tokens until they migrate, but new integrations should treat tokens as a legacy compatibility path.
## The Mental Model
Think of offline licensing like a boarding pass:
1. **Check-in (Activation)**: Your user activates their license online. This "checks them in" and reserves their seat.
2. **Boarding Pass (Machine File)**: They receive a cryptographically signed "boarding pass" that proves they're authorized.
3. **Offline Travel**: They can board the plane (use your software) without needing to call the airline (your server) again—the boarding pass is proof enough.
4. **Expiration**: The boarding pass has an expiration. Eventually, they need to check in again.
The key insight: **the seat is consumed at activation, not when generating offline credentials**. This prevents users from getting unlimited offline artifacts without using their seat allocation.
---
## How It Works
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ ONLINE (Internet Available) │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User enters license key │
│ │ │
│ ▼ │
│ 2. SDK calls: POST /activate │
│ │ │
│ ▼ │
│ 3. Server validates license, consumes seat, creates activation │
│ │ │
│ ▼ │
│ 4. Machine-file-capable SDK fetches the encrypted offline certificate │
│ │ │
│ ▼ │
│ 5. Machine file stored securely on device │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
│
│ User goes offline
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ OFFLINE (No Internet) │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. App launches, SDK tries online validation → fails (no network) │
│ │ │
│ ▼ │
│ 2. SDK falls back to offline validation │
│ │ │
│ ▼ │
│ 3. Offline artifact signature verified (Ed25519 cryptography) │
│ │ │
│ ▼ │
│ 4. Machine file decrypted using device fingerprint (machine-file flow) │
│ (Only works on the ORIGINAL device—not transferable!) │
│ │ │
│ ▼ │
│ 5. Expiry checked → License valid! │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Why This Is Secure
1. **Activation required first**: You can't skip straight to offline. The seat must be consumed.
2. **Device-bound**: The machine file is encrypted with a key derived from your device's unique fingerprint. Copy it to another computer? It won't decrypt.
3. **Cryptographically signed**: The server signs the machine file. Tamper with it? The signature fails.
4. **Time-limited**: Machine files expire. Users must eventually reconnect.
---
## Real-World Scenarios
### Scenario 1: Hustl User on a Flight
**Context**: Sarah uses [Hustl](https://gohustl.co) (a macOS productivity app) and is about to board a 14-hour flight to Tokyo.
**What happens:**
| Timeline | What Sarah Does | What LicenseSeat Does |
|----------|-----------------|----------------------|
| **Before flight** | Opens Hustl, already activated | Swift SDK refreshes its signed compatibility token in the background |
| **On the plane** | Enables airplane mode, opens Hustl | SDK tries online validation → timeout → falls back to the protected token |
| **During flight** | Works for 14 hours | SDK binds the signature, canonical payload, license, product, fingerprint, and time claims locally |
| **Lands in Tokyo** | Connects to hotel WiFi | SDK auto-validates online and refreshes its offline assets |
**Sarah's experience**: Completely seamless. She didn't even notice the offline/online transitions.
### Scenario 2: Music Producer on a China Tour
**Context**: Alex is a DJ with a $299 music plugin (like a synth or effects processor). He's touring China for 30 days where internet is unreliable and VPNs often get blocked.
**What happens:**
| Timeline | What Alex Does | What LicenseSeat Does |
|----------|-----------------|----------------------|
| **Before trip** | Opens plugin, clicks "Prepare for Offline" | SDK requests extended machine file (30-day TTL) |
| **Day 1-15** | Performs at venues, no reliable internet | Offline validation succeeds every time |
| **Day 20** | Plugin shows "Offline license expires in 10 days" | SDK warning based on machine file expiry |
| **Day 25** | Finds working VPN for 5 minutes | SDK opportunistically refreshes machine file |
| **Day 26-30** | Back to no internet | Continues working with refreshed credentials |
**Alex's experience**: One minor warning notification. Zero interruption to his performances.
### Scenario 3: Industrial Equipment in Remote Location
**Context**: A robotics company deploys autonomous equipment with your licensed software. The equipment operates in locations with zero internet access for months at a time.
**What happens:**
| Timeline | What Operator Does | What LicenseSeat Does |
|----------|-----------------|----------------------|
| **At HQ** | Provisions device with license | Standard activation → gets 365-day machine file |
| **Deployment** | Ships to remote site, no internet | N/A |
| **Day 1-180** | Equipment operates autonomously | Offline validation on each startup |
| **Day 300** | Equipment shows "License expires in 65 days" | Warning based on machine file expiry |
| **Before expiry** | Technician visits site with laptop | Repeats the manual air-gapped renewal flow: activate/check machine file from a connected admin system, then transfer the refreshed certificate |
**Operator's experience**: Set it and forget it for almost a year.
---
## Configuration Options
You control offline behavior per license plan in your LicenseSeat dashboard:
| Setting | Description | Recommended |
|---------|-------------|-------------|
| **Offline TTL** | How long the machine file is valid | 30 days for consumer apps, 90-365 days for enterprise |
| **Grace Period** | Extra time after TTL expires before hard lockout | 7 days (gives users time to reconnect) |
| **Require Activation** | Must activate online before getting offline credentials | Always enabled for security |
### Example Plan Configurations
**Consumer App (Hustl-style)**:
- Offline TTL: 30 days
- Grace Period: 7 days
- Seat Limit: 2 devices
**Professional Plugin (Music production)**:
- Offline TTL: 45 days
- Grace Period: 30 days
- Seat Limit: 2 devices
**Enterprise/Industrial**:
- Offline TTL: 365 days
- Grace Period: 30 days
- Seat Limit: 10+ devices
---
## SDK Integration
### Automatic (Recommended)
Supported SDKs handle their own offline artifact automatically after activation.
Depending on the SDK generation:
- newer SDKs such as C++ and Rust/Tauri cache machine files
- Swift 0.4.2 and older SDK generations cache signed offline tokens until they migrate
```cpp
licenseseat::Config config;
config.api_key = "pk_live_xxxxxxxx";
config.product_slug = "my-app";
config.storage_path = "/path/to/cache";
licenseseat::Client client(config);
auto activation = client.activate("USER-KEY");
```
### Manual Control
For advanced or air-gapped use cases, you can manage the offline artifact manually.
The currently supported manual flow is:
1. collect the fingerprint on the target machine
2. call `activate`
3. call `machine-file`
4. transfer the returned certificate manually
See [[guides/air-gapped-licensing|Air-Gapped Licensing]] for the full operator-assisted workflow.
> **Important:** LicenseSeat does not currently ship a built-in challenge-response product flow, QR ceremony, or dedicated offline-activation endpoint. Those are future workflow possibilities, not current product behavior.
---
## What Users See
### Normal Operation
Users don't see anything special. The app just works, online or offline.
### Near Expiry Warning
When offline credentials are about to expire and the user hasn't connected:
```
┌─────────────────────────────────────────────────────────────────┐
│ ⚠️ Offline license expires in 3 days │
│ │
│ Connect to the internet to extend your offline access. │
│ │
│ [Remind Me Later] [Connect Now] │
└─────────────────────────────────────────────────────────────────┘
```
### Expired (with Grace Period)
If expired but still in grace period:
```
┌─────────────────────────────────────────────────────────────────┐
│ ⏰ Offline license expired │
│ │
│ Your offline access has expired. Please connect to the │
│ internet to continue using all features. │
│ │
│ Limited mode: You can still open existing projects. │
│ │
│ [Connect Now] │
└─────────────────────────────────────────────────────────────────┘
```
### Fully Expired
```
┌─────────────────────────────────────────────────────────────────┐
│ 🔒 License validation required │
│ │
│ Please connect to the internet to validate your license. │
│ │
│ [Try Again] [Enter New Key] │
└─────────────────────────────────────────────────────────────────┘
```
---
## Security Details
### Device Binding
Machine files are encrypted with a key derived from:
```
encryption_key = SHA256(license_key + device_fingerprint)
```
This means:
- ✅ Works on the original device
- ❌ Won't decrypt on a different device (different fingerprint = different key)
- ❌ Won't decrypt with a different license key
The API treats the fingerprint as an opaque, stable installation identifier.
Its source is SDK-specific:
- Swift 0.4.2 creates a random app-scoped identifier and protects it in
Keychain; it does not derive the default from a hardware UUID.
- Rust 0.6.0 likewise defaults to a durable random installation UUID, scoped
to the app/product and persisted in application data, with existing legacy
identifiers adopted where safe. Hardware-derived identity remains available
only by explicit opt-in: `send_fingerprint_components = true` enables raw
hardware component collection, and a machine-file checkout can supply a
per-request component map without the global opt-in.
- Current C++ clients hash a preferred platform signal (macOS platform
UUID, Windows Machine GUID, or Linux machine ID/DMI value) into the canonical
fingerprint. Their machine-file checkout can also send structured source
components, including a hostname fallback, for matching and diagnostics.
- Custom integrations may supply their own stable identifier, but must persist
it across normal launches and upgrades.
Fingerprints and source components are device data. Do not describe them as
anonymous, and disclose/retain them according to the host application's privacy
obligations. Changing the identifier after activation consumes or targets a
different seat.
### Signature Verification
Every machine file is signed with Ed25519:
```
signature = Ed25519_Sign("machine/" + encrypted_payload, server_private_key)
```
The SDK verifies this signature using the public key (embedded in your app or fetched once). If anyone modifies the machine file, the signature check fails.
### Clock Tamper Detection
The SDK tracks the last time it successfully validated. If the system clock suddenly jumps backwards (user trying to extend expired license), the SDK detects this and flags it as suspicious.
---
## FAQ
### Q: What if a user copies the machine file to another computer?
**A**: It won't work. The machine file is encrypted with a key that includes the device fingerprint. Different device = different fingerprint = decryption fails.
### Q: What if a user never connects after activation?
**A**: Their offline credentials will eventually expire (based on your configured TTL). After expiry + grace period, they'll need to reconnect. You control how long this window is.
### Q: Can I revoke a license while someone is offline?
**A**: Not immediately—that's the tradeoff of offline support. The machine file is self-contained. However:
- The machine file will expire at its TTL
- When they reconnect, the revocation takes effect immediately
- For high-security needs, use shorter TTLs
### Q: What happens if the user's hardware changes (new motherboard)?
**A**: The fingerprint changes, so the machine file won't decrypt. They'll need to re-activate online. If they're at their seat limit, they may need to deactivate an old device first.
### Q: Can users game the system by staying offline forever?
**A**: No. Machine files have a maximum TTL (you configure this). Even a 365-day TTL eventually expires. Most apps use 30 days.
### Q: Does offline validation work in VMs or containers?
**A**: Yes, but you should ensure stable fingerprints:
- **VMs**: The fingerprint should remain stable across reboots
- **Containers**: Use a persistent volume to store the fingerprint, or inject it via environment variable
---
## Next Steps
- [[guides/air-gapped-licensing|Air-Gapped Licensing]] - For devices that never connect
- [[sdks/swift|Swift SDK]] - Detailed offline configuration for Apple platforms
- [[sdks/cpp|C++ SDK]] - Offline support for native applications
- [[api-reference/signing-keys|Signing Keys API]] - For advanced integrations
---
## Operating sales connections
# Operating sales connections
A successful connection receives a provider event, matches a linked product, issues the expected license quantity, assigns the buyer, and delivers a key when email delivery is enabled. Verify each step separately. A provider's HTTP 200 proves receipt, and an **Active** connection proves a successful fulfillment has happened; neither guarantees every later event succeeded or an email reached an inbox.
## Email and ownerless licenses
Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. If delivery is disabled, licenses still issue. Check the license and email delivery separately during testing, including spam filtering. Local development can use a mail catcher instead of real inbox delivery.
An order with no email can issue an ownerless license. An organization owner/admin can open it, use **Assign customer**, enter the buyer's email and optionally request a license email. The email option respects the organization's plan and delivery setting. Assignment cannot replace an existing owner. Ownerless licenses can also be revoked from their license page or by a correlated refund.
Paddle is different: transaction events normally have only a customer ID. LicenseSeat waits for signed customer history or checkout custom data to supply the email; subscribe to its customer events.
## Subscription access
Use a finite license plan whose duration matches the billing interval. Initial purchases grant the plan's duration. Free-trial signup can grant this duration too: automatic synchronization of trial ends or the initial provider billing period is not provided. A subscription mapped to a perpetual plan still issues a license, preserving existing seller configurations. When the purchase identifies a subscription, its event shows a **Subscription plan warning** in the event list and detail page. Order-only payloads may not identify a subscription yet, so review the mapping before selling. Cancellation does not revoke perpetual access. Changing a mapping does not retroactively change existing license expiry.
Stripe, Paddle and Polar renewals use the matching provider billing-period end without shortening access already granted. Gumroad and Lemon Squeezy renewals use the provider payment timestamp plus the license plan duration. A fixed day count does not exactly model every calendar month; configure it deliberately and test expected dates. Cancellation, a failed payment or a plan-change event does not automatically revoke or change plans. Without another renewal, finite access ends at the license's stored expiry. Existing perpetual licenses stay perpetual: Stripe/Paddle/Polar renewal targets do not shorten them; Gumroad/Lemon Squeezy renewals on active perpetual licenses are acknowledged without inventing a finite expiry, with a warning on the event. Refunds can still revoke them.
Shopify paid orders are treated as one-time purchases; selling plans and subscription apps are not synchronized.
Polar subscriptions renew through `order.paid` with the `subscription_cycle` billing reason. Initial access follows the mapped plan duration, including trials. Cancellations, pauses and revocations are ignored; finite access ends at its stored expiry, while perpetual access remains. Initial seat quantity is automated; later seat changes and member assignments are not synchronized. See [[guides/polar|the Polar guide]] for these limits.
Refunds/disputes can revoke the whole correlated subscription, including access paid for by later invoices. Partial-refund rules differ by provider: consult its guide. Winning a dispute does not automatically reinstate access. A revoked license is not restored by an ordinary renewal.
## Test before taking real payments
1. Create the provider's sandbox/test destination and use its own secret and product IDs. Use a separate LicenseSeat organization when you need full separation: mappings are shared across connections for the same provider within an organization.
2. Link the product **before** making the purchase. Synthetic provider samples often use unrelated product IDs or omit buyer information.
3. Verify receipt, processed status, product/plan, license quantity, owner, expiry and email delivery. A signed simulation can create real LicenseSeat licenses and send real emails; it is not automatically a dry run. Gumroad `test=true` pings are the exception: only the connection's received timestamp changes.
4. Test a refund and check the license becomes revoked. For subscriptions also test a renewal and a refund/dispute of a renewal.
5. Repeat the setup with live IDs and secrets before selling. A successful test destination does not prove live delivery. Revoke your test licenses afterwards.
## Recover a missed purchase, renewal or refund
LicenseSeat stores accepted events before background processing. Provider delivery retries and LicenseSeat processing retries are separate. Processing has finite automatic attempts. A periodic recovery job re-enqueues receipts left pending and renewal/customer-history failures when newer history for the same organization/provider arrives. Unmatched refunds and disputes are **Ignored**, with an explanation that no matching license was found; they do not consume repeated job attempts or create red failures for unrelated merchandise. Their signed payloads are retained. Every five minutes, the recovery job checks at most 100 eligible events, rotating checked unmatched reversals to the back of the queue. It enqueues an unmatched reversal only when retained history now resolves it to an actual license. Larger backlogs can take several sweeps. A later purchase also checks retained reversals before emailing an active key. The job does not invent missing provider data or continuously restart malformed events.
Open the event's detail page from the connection's event list and inspect its status and error:
- **No fulfillment target / no active mapping:** link the correct ID, then use **Retry** on the original ignored purchase. Duplicate-fulfillment events cannot be retried into additional licenses.
- **Ignored refund/dispute with no matching license:** for an unlicensed product, no action is needed. For a licensed purchase, recover the matching purchase/payment history; the retained reversal can then resolve automatically, or use **Retry Processing**. Unrelated new sales do not restart it.
- **Missing purchase or subscription:** resend the original purchase and subscription history from the provider, then retry the renewal/reversal. Manually issuing a license with the same email does **not** link it to the provider subscription.
- **Stripe subscription refund/dispute:** on Basil and later, subscribe to `invoice_payment.paid`, replay that relationship event and the associated `invoice.paid` or Checkout history, then retry the reversal. Relationship events may appear ignored while still providing correlation history.
- **Paddle awaiting customer history:** replay the customer notification or use checkout custom data for future transactions. A no-op customer save is not guaranteed to generate a new event.
- **Invalid data, ambiguous mapping or incompatible plan:** correct the source/configuration and retry the relevant event. Read the error instead of repeatedly replaying unchanged input.
- **Old refund marked processed but access still active:** processed reversal events expose **Retry** for repair of historical unmatched refunds. Reprocessing a matched reversal is idempotent. Retained reversals are also checked before a newly arriving purchase can email a fresh active key.
If the provider no longer retains the required history, manage the affected license manually. There is no automatic import of pre-existing subscriptions. For urgent refunds, revoke the known license directly while resolving the event history.
## References
- [Stripe delivery ordering and retries](https://docs.stripe.com/webhooks#event-ordering)
- [Stripe Invoice Payment object](https://docs.stripe.com/api/invoice-payment/object)
- [Paddle webhook delivery](https://developer.paddle.com/webhooks/about/respond-to-webhooks)
- [Shopify HTTPS delivery](https://shopify.dev/docs/apps/build/webhooks/subscribe/https)
- [Polar webhook delivery](https://docs.polar.sh/integrate/webhooks/delivery)
- [Lemon Squeezy webhook events](https://docs.lemonsqueezy.com/help/webhooks/event-types)
- [Gumroad Ping](https://gumroad.com/ping)
Provider documentation is authoritative for their wire contracts and dashboards. This guide describes LicenseSeat's access and recovery policy; provider features are not automatically supported just because the provider offers them.
---
## Shipping Desktop Updates
# Shipping Desktop Updates
How to take a desktop app from "I have a build on my laptop" to "my users'
apps update themselves", using LicenseSeat Distribution.
This guide is written to be executed — by a person or by a coding agent — and
it is deliberately specific. Every pitfall in it is one that actually happened
while shipping a real app, and several of them **produce a perfectly valid
release that no client can install**: correct digest, valid signature, correct
feed, dead update. Those are the expensive ones, so they are called out where
you would hit them rather than in a footnote.
Companion reading: [[guides/agentic-integration-playbook|The Agentic Integration Playbook]]
covers the licensing model and the wire contracts; this guide is the artifact
and updater half.
---
## The Mental Model
Three things have to line up, and they are owned by different parties:
```
YOUR BUILD LICENSESEAT YOUR APP
┌───────────┐ ┌─────────────┐ ┌────────────┐
│ .app.tar │─upload──▶│ verifies │ │ updater │
│ .dmg .exe │ │ digest │ │ reads feed │
│ .AppImage │ │ SIGNS it ───┼──feed───▶│ verifies │
└───────────┘ │ serves feed │ │ signature │
▲ └─────────────┘ │ installs │
│ │ └────────────┘
you package we hold the ▲
the bytes private key │
you embedded the
PUBLIC key at build time
```
1. **You package the bytes.** LicenseSeat never repackages; what you upload is
what your users download, byte for byte. Packaging mistakes are therefore
*yours to make and yours to prevent* — see [Packaging](#packaging), which is
where the worst failures live.
2. **LicenseSeat signs and serves.** The private key never leaves the server;
signing happens at finalize, not at upload.
3. **Your app verifies.** It carries the public key, compiled in at build time.
That is the whole security property: a build your users accept is one your
LicenseSeat account signed.
The consequence that catches people: **the public key must be embedded before
you ship version 1**. Change it later and every already-installed copy refuses
every future update, permanently. There is no recovery except telling users to
download a fresh installer manually.
---
## The Publish Pipeline
Four calls, in order. The engine API is keyed by **version** (the dashboard's
upload form performs the same sequence through its own controllers, layering
an extra macOS installability check on top — see below):
```
1. POST /api/v1/products/:slug/releases → draft release
2. POST …/releases/:version/artifacts → presigned PUT
3. PUT (the bytes) → storage
4. POST …/releases/:version/artifacts/:id/finalize → verify + SIGN
5. POST …/releases/:version/publish → feeds go live
```
### Step 2 — registering the artifact
You declare the bytes before you send them:
```json
{
"filename": "MyApp-2.0.1-aarch64.app.tar.gz",
"byte_size": 18409016,
"content_type": "application/octet-stream",
"md5_base64": "…",
"sha512": "…"
}
```
- **`md5_base64` is mandatory** — the presigned PUT is Content-MD5 bound, so
storage itself rejects bytes that do not match. You cannot upload something
other than what you declared.
- **`sha512` is optional but strongly recommended.** It is what finalize
re-verifies server-side after re-streaming the object. Browsers cannot hash
files above roughly 512 MB in one pass (`crypto.subtle.digest` needs the whole
buffer in memory), so a browser client may legitimately omit it; a CLI or
agent has no such excuse and should always send it.
- **Registration is idempotent-ish in practice**: a failed upload can reuse the
same draft release and registered artifact. Do not create a new release to
retry, or you will trip the version-uniqueness validation.
### Step 4 — finalize is where signing happens
Finalize re-streams the stored object, recomputes the digest, compares it to
what you declared, and **only then** signs with the product's active key. A
mismatch fails the release rather than publishing something unverified.
This means: **an artifact finalized while no signing key is active is stored,
served, and completely unsigned.** No error, no warning in the feed. Every
updater that verifies signatures will refuse it, and you will find out from
user reports. Generate or import your key *before* your first finalize.
---
## Packaging
The single largest source of "everything looks right and nothing works".
### Filename drives platform detection
Platform, architecture and kind are inferred from the filename. Get it right
and you never think about it again:
| Filename | platform | arch | kind |
| --- | --- | --- | --- |
| `MyApp-2.0.1-aarch64.app.tar.gz` | macos | aarch64 | app_tar_gz |
| `MyApp-2.0.1-x64.dmg` | macos | x86_64 | dmg |
| `MyApp-Setup-2.0.1.exe` | windows | x86_64 | nsis |
| `MyApp_2.0.1_amd64.AppImage` | linux | x86_64 | appimage |
Ambiguous containers (`.zip` especially) may need an explicit platform
override. If a published release shows `platform: any` while its artifacts
detected fine, that is the *release* row, not a detection failure — releases
span platforms by design.
### macOS: strip extended attributes, or ship a broken update
> **This will bite you.** A freshly built `.app` carries
> `com.apple.provenance` extended attributes on every file. macOS `tar` stores
> those as PAX records. Tauri's updater unpacks with a Rust tar implementation
> that tries to materialise them as AppleDouble files and dies with:
>
> ```
> failed to unpack `._MyApp.app` into `/var/folders/…/T/tauri_updated_appXXXX/`
> ```
>
> The archive **lists cleanly** — `tar -tzf` shows no `._` entries — because
> the attributes are metadata records, not files. The digest matches. The
> signature verifies. The feed is perfect. And the update fails on every
> machine.
Package macOS bundles like this:
```sh
xattr -cr MyApp.app # strip provenance/quarantine
COPYFILE_DISABLE=1 tar --no-xattrs -czf \
MyApp-2.0.1-aarch64.app.tar.gz MyApp.app # no AppleDouble, no PAX xattrs
```
> **The dashboard publish flow refuses these archives at finalize**, before
> signing or publishing, and the error carries the repackaging command. You
> will find out in ten seconds rather than from support mail. The engine API's
> finalize does **not** run this inspection — if you publish via the API
> (which agents should), the pre-upload `tar -tvf` check above is your
> mandatory gate, not a nicety. Treat the dashboard check as a safety net
> rather than a substitute for packaging correctly: it inspects the archive's
> opening entries, which is where the defect always appears in practice, and
> it deliberately stays silent when it cannot read an archive — a storage
> hiccup must never block a valid release.
Verify before uploading — the check costs a second and the failure costs a
release:
```sh
tar -tvf MyApp-2.0.1-aarch64.app.tar.gz | grep -i "\._" && echo "BAD: AppleDouble entries"
xattr -lr MyApp.app | head # should print nothing
```
### The archive layout matters
For macOS, the `.app` bundle must be at the **root** of the archive, not
nested in a directory:
```
MyApp.app/ ✅
MyApp.app/Contents/…
release/MyApp.app/ ❌ the updater will not find it
```
---
## Wiring Your Updater
Configure one, ignore the rest. LicenseSeat generates every feed regardless,
but you only need the one your app reads.
### First: put the feed behind a domain you own
The feed URL you are about to configure gets **compiled into every copy of
your app you ever ship**. Whatever domain that URL names is the domain those
copies are married to, forever — there is no fixing it later for versions
already installed. So before wiring anything, spend five minutes making that
URL yours:
1. **DNS record** for `updates.yourapp.com` at your DNS provider. On
Cloudflare: an `A` record to `192.0.2.1` with the proxy enabled — the
address is a documentation placeholder (RFC 5737); the proxy answers, not
the address.
2. **Redirect rule** (Cloudflare: Rules → Redirect Rules): requests matching
`updates.yourapp.com/*` → dynamic target
`concat("https://licenseseat.com/p/", substring(http.request.uri.path, 0))`,
status **308**, preserve query string. Any CDN or reverse proxy that can
issue a path-preserving 308 works the same way.
3. **Verify the chain before you ship** — a redirect that drops the path or
the query string fails silently as "no update available":
```sh
curl -sIL "https://updates.yourapp.com/tauri/latest.json?channel=beta" | grep -E "HTTP|location"
# expect: 308 → licenseseat.com/p//tauri/latest.json?channel=beta → 200/204
```
Then use `https://updates.yourapp.com/…` everywhere the sections below say
`https://licenseseat.com/p//…`.
**If the same subdomain serves anything else, scope the rule.** A blanket
`/*` redirect owns the entire hostname — the moment that host also serves a
fallback bucket, a status page, or any static object, the redirect silently
captures those paths too, and the failure hides until the day the fallback
is actually needed. Use a custom filter expression instead of the wildcard
template, excluding the paths the host serves directly:
```txt
http.host eq "updates.yourapp.com"
and not starts_with(http.request.uri.path, "/fallback/")
```
with the same dynamic 308 target and query-string preservation. The
exclusion list and whatever writes to those paths must move together — a
prefix added on one side and not the other is this exact failure again.
Why we tell you this even though it loosens your coupling to us: if you ever
move providers — including away from LicenseSeat — you re-point one DNS
record and every installed copy follows. Skip it and your update path is
hostage to a hostname you don't control. Updaters follow redirects
(Tauri/reqwest, Sparkle, electron-updater all do), and the pattern runs in
production today: `updates.clipbasket.com` is exactly this recipe, and it
carried a real publish → verify → install → relaunch cycle through the
redirect, query strings intact, before this section was written.
### Tauri
```json
"plugins": {
"updater": {
"endpoints": ["https://licenseseat.com/p//tauri/latest.json"],
"pubkey": ""
}
}
```
- **Static vs dynamic.** `…/tauri/latest.json` is a static manifest listing every
platform. The dynamic form,
`…/tauri/{{target}}/{{arch}}/{{current_version}}`, lets the server decide:
**200 with an update, 204 when the client is already current.** Prefer dynamic
if you want the "no update" case to cost nothing.
- **Target keys must match.** A macOS arm64 `.app.tar.gz` publishes under
`darwin-aarch64-app`. If your client looks for `darwin-aarch64` and the feed
only carries `-app` (or vice versa), it silently finds no update. The log line
to look for is `Searching for updater target '' in release data`.
- **`pubkey` is the base64-wrapped form** shown in the dashboard, not the raw
minisign `.pub` document. They are different encodings of the same key and
mixing them up produces a verification failure that reads like corruption.
### Sparkle
```xml
SUFeedURLhttps://licenseseat.com/p//appcast.xmlSUPublicEDKey
```
**If no Sparkle key is active when you finalize, the appcast item carries no
`sparkle:edSignature`** — and a Sparkle app configured with `SUPublicEDKey`
refuses unsigned items. The feed will look fine to you. Check for the attribute
explicitly:
```sh
curl -s https://licenseseat.com/p//appcast.xml | grep -c edSignature # must be ≥ 1
```
### electron-builder
```yaml
publish:
provider: generic
url: https://licenseseat.com/p//electron
```
electron-updater verifies a **SHA-512 from `latest.yml`** rather than a
signature, so there is no key to manage — and no signing key to forget.
### Anything else
```sh
curl https://licenseseat.com/p//latest
```
Returns version, download URL and SHA-512. Enough to roll your own check in
any language.
---
## The Download Link on Your Website
Never link a versioned artifact URL from your website. The canonical
`/d//` URLs that appear in feeds point at one specific
upload — hard-code one into a download button and it is stale the moment you
publish the next version, handing every new visitor an app whose first act is
asking to update itself.
Link the permalink instead:
```
https://licenseseat.com/p//download
```
Every click 302s to the newest installable artifact, so the button you wire
today still serves whatever version is current years from now.
- **Platform.** `…/download/macos`, `…/download/windows`, `…/download/linux`
pin it. The bare URL decides from the visitor's browser; phones deliberately
match nothing (an Android UA says "Linux", an iPhone says "Mac OS X") and
fall back to the overall best artifact, as do `curl` and scripts — pin the
platform in anything automated.
- **Channel.** A path segment after the platform: `…/download/beta`,
`…/download/macos/beta` (order is platform, then channel; a lone segment is
unambiguous because the vocabularies do not overlap). `?channel=beta` works
too. Channels are `stable`, `beta`, `alpha`, and follow the same hierarchy
as the feeds: a beta subscriber also sees stable releases, and whichever is
newest wins.
- **Architecture.** `?arch=aarch64`, with the spellings people actually type
(`arm64`, `x64`, `amd64`) accepted. Without it, universal builds win, then
the most compatible arch. An arch nothing satisfies is a 404; an arch we
do not recognize is ignored rather than breaking the link.
- **What it picks.** The thing a person should download, not the thing an
updater should: the DMG over the Sparkle zip, the setup exe over the MSI,
the AppImage over deb/rpm — and never deltas or blockmaps. A release that
shipped only updater artifacts falls through to the newest release with a
real installer.
- **Gating.** On a `licensed` feed policy this endpoint returns a real 403 to
anonymous requests (it serves people, not update clients, so no
empty-feed politeness). A request carrying a license key
(`Authorization: Bearer` or `?license_key=`) downloads the newest version
that license is entitled to — a lapsed maintenance window serves the last
entitled release, same as the feeds.
Downloads through the permalink are counted and attributed in the dashboard's
download analytics exactly like feed-driven downloads.
---
## Signing Keys
**What to tell your users' future selves:** every build is signed with a
private key only LicenseSeat holds; the public half lives in your app and lets
it prove an update came from you and was not modified in transit.
Operational rules that matter more than the cryptography:
- **Secret material is shown exactly once**, at generation. There is no
re-download endpoint, deliberately: a show-once guarantee with a permanent
re-download route one page over is not a guarantee. If you lose it, retire the
key, generate a new one, and re-embed the new public key — which only helps
versions you ship *after* that point.
- **Migrating an app that already has users? Import, do not generate.** Copies
already installed only accept updates signed with the key they were built
against. Generating a fresh key strands every one of them permanently. The
dashboard has an import path for exactly this.
- **Retiring a key does not break installed apps** — they keep verifying with
the key they carry. It breaks *future* releases until you generate or import a
replacement.
- **One key per purpose per product.** Sparkle uses Ed25519, Tauri uses
minisign; they are not interchangeable.
---
## Update Feed Visibility
Two policies, and the choice is about who may download, not about tiers:
- **Open** — anyone can see and download updates. What most apps do.
- **Licensed** — feeds are empty unless the request carries a valid license
key; lapsed licenses are windowed to their `updates` entitlement.
Two rules worth internalising:
1. **Never gate reads on billing state.** A seller who downgrades should stop
*uploading*; their already-shipped users must keep updating. Punishing end
users for their vendor's plan choice turns into your reputation problem.
2. **Licensed feeds change your support load.** Every update failure becomes
"is my license OK?" Choose it when piracy actually costs you, not by default.
### Maintenance windows: updates that lapse without punishing anyone
On a licensed feed, a license's access to *versions* is bounded by its
`updates` entitlement:
- No `updates` entitlement, or one with no expiry → every release, forever.
- An `updates` entitlement with an expiry → only releases **published
before** that instant. Publication time, exclusive — a release published
at the exact expiry does not qualify.
A lapsed customer keeps what they paid for: their feed still serves the last
entitled version (never an error state), the download permalink serves the
newest entitled release, and download-authorization tokens re-check the
cutoff at download time, so a token minted before a lapse cannot outlive it.
Renewals just move the expiry — nothing else to migrate. This is the
"perpetual license + one year of updates" model, and one plan entitlement is
the whole implementation:
```json
{ "key": "updates", "duration_type": "fixed", "duration": "1.year" }
```
### Version ceilings: the paid-major-upgrade model
Beside the time window, the `updates` entitlement can carry a **version
ceiling** — the "this license receives all 3.x" model as data the platform
enforces, instead of a check every app reimplements:
```json
{ "key": "updates", "duration_type": "perpetual", "below_version": "4.0.0" }
```
The ceiling is **exclusive** and compares **core versions**: `below_version:
4.0.0` covers every `3.x` release including `3.9.0-beta.1`, and does NOT
cover `4.0.0` — or `4.0.0-beta.1`, because nobody selling "all 3.x updates"
means betas of the next major. It is enforced everywhere the time window is:
licensed feeds, the download permalink, gated downloads, and download
tokens (revalidated live at redemption). A 3.x-only license is never
*offered* 4.0 — which its app would otherwise install and then refuse to
run. Both bounds compose: a license can have a year of updates AND a 4.0.0
ceiling. Rules worth knowing: releases without parseable semver are never
served to a version-bounded license (fail closed), the ceiling must be a
plain `x.y.z` (prerelease ceilings are rejected at write time), and it only
exists on the `updates` entitlement.
**The ceiling also gates activation and validation** — whenever the
requesting app declares what version it is. Send `software_version` on
`activate` or `validate` and a license bounded below that version refuses:
activation fails with `version_not_entitled` (403), validation answers
`valid: false, code: "version_not_entitled"` in its normal 200 envelope,
and both are audited. Two details worth knowing: the official SDKs already
declare a version implicitly — the telemetry envelope's `app_version` rides
every licensing call when telemetry is enabled — and declared versions
parse leniently (`3.0` counts as `3.0.0`; a version string we cannot parse
skips the gate rather than bricking the app, because this is honest-client
gating, not DRM).
The principle that makes upgrade flows work: **the key proves who you are;
entitlements say what you get.** A request that declares no version — your
website's key-check, an upgrade-discount flow, an old client — still
*validates*: proving ownership is exactly what those flows need, and the
gate only ever fires against a version the app itself declared.
### Grandfathering: existing customers into a new major, no reissue
When you ship 4.0 and want some or all 3.x customers to have it free, do
not issue new keys — the key is the customer's identity, and activations,
machine files, and history hang off it. Raise their ceiling instead: each
plan page's **"Version coverage"** card shows what new licenses will get
and what the existing fleet actually has, and applies a ceiling to the
existing licenses (with an optional "only purchases since" date, so a
launch grace window is one click). By default the apply **only ever
raises** ceilings — backed by
`Grandfathering.raise_updates_ceiling!`: unbounded licenses are never
touched, re-running is a no-op, every change is audited, so the default
cannot take anything away from anyone.
The same card also handles the opposite move — a **paid discontinuation**
("existing keys are for 2.x; 3.x needs a new purchase"): checking *allow
reducing* imposes the ceiling exactly on every selected license, including
unbounded ones, via `Grandfathering.set_updates_ceiling!`. Deliberately an
explicit checkbox with its own warning: this is the direction that takes
access away. Expiries are preserved and every change carries its previous
value in the audit trail either way.
---
## Verifying It Actually Works
Do not trust the dashboard's word, or this guide's. The following sequence
proves the whole chain with independent tools, and an agent should run it after
the first publish of any product.
```sh
BASE=https://licenseseat.com/p/
# 1. The feed serves the version you published
curl -s "$BASE/tauri/latest.json" | jq '.version'
# 2. The advertised bytes are the bytes you uploaded
URL=$(curl -s "$BASE/tauri/latest.json" | jq -r '.platforms["darwin-aarch64-app"].url')
curl -sL "$URL" -o served.tar.gz
cmp served.tar.gz MyApp-2.0.1-aarch64.app.tar.gz && echo "byte-identical"
# 3. The signature verifies with a tool that is not ours
curl -s "$BASE/tauri/latest.json" | jq -r '.platforms["darwin-aarch64-app"].signature' \
| base64 -d > update.minisig
minisign -V -p product.pub -x update.minisig -m served.tar.gz
# 4. Version semantics: an old client is offered the update, a current one is not
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/tauri/darwin/aarch64/1.0.0" # 200
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/tauri/darwin/aarch64/2.0.1" # 204
```
Then do the thing none of that proves: **run the previous version and let it
update itself.** Feed correctness and installability are different properties,
and only the second one is what your users experience.
---
## Failure Signatures
What you will actually see, and what it means:
| Symptom | Cause |
| --- | --- |
| `failed to unpack '._MyApp.app'` | macOS xattrs stored as PAX records — repackage with `xattr -cr` + `--no-xattrs` |
| Dashboard finalize returns 422 mentioning `xattr -cr` | The same defect, caught at upload instead of on your users' machines (dashboard flow only — API finalize does not inspect archives). Repackage and re-upload; nothing was signed or published |
| `StartingBinary found current_exe() that contains a symlink` | App installed under a symlinked path (`/tmp` → `/private/tmp`). Install to a real path |
| Updater finds nothing, feed looks correct | Target key mismatch (`darwin-aarch64` vs `darwin-aarch64-app`); check the "Searching for updater target" log line |
| Updater finds nothing through a custom domain, direct feed URL works | The redirect drops the path or the query string (a plain CNAME cannot rewrite paths; a static redirect target loses `?channel=`). Re-test with `curl -sIL` and a query string |
| Sparkle refuses the update | No `sparkle:edSignature` — no Sparkle key was active at finalize |
| Signature verification fails | Wrong key encoding (base64 config form vs `.pub` document), or the app was built before the key existed |
| Update check never runs on relaunch | Client-side throttle persisted to disk (e.g. `update-checks.json`); clear it or wait out the interval |
| Release build refuses to compile | Release guards demand production signing config and HTTPS endpoints — by design; use a debug build for local testing |
| Storage rejects the PUT | The bytes changed after you computed the MD5, or you retried with a stale presigned URL |
| Upload rejected with a quota message | Storage or artifact-size limit; the dashboard's JSON error carries an `upgrade_url` when the refusal is resolvable (the API's 403 `distribution_quota_exceeded` carries the reason string only) |
---
## Testing Locally
You will want to prove the loop before pointing real users at it.
- **Build a debug bundle, not a release one.** Release builds are fenced: they
require production signing configuration and reject plain-HTTP endpoints. That
guard is doing its job — do not weaken it. A debug build runs the same updater
and verifies signatures identically.
- **Point the build at your local server** by overlaying the updater config at
build time rather than editing committed files.
- **Install to a real path**, not `/tmp` (see the symlink failure above).
- **Clear the update-check state** between runs, or the client's throttle will
make it look like nothing is happening.
- **Use a throwaway product** for the rehearsal. Test releases in the product
your customers actually update from will be visible to them.
---
## For Coding Agents
If you are automating this end to end, the failure modes above are not
theoretical — they are what separates "the API returned 200" from "the user's
app updated". Concretely:
1. **Verify installability, not just publishability.** Every API call can
succeed while producing an update nothing can install. The packaging check
(`tar -tvf | grep '\._'`) and a real install are the only proofs.
2. **Check for a signing key before finalize**, and refuse to proceed without
one unless the framework is digest-based. An unsigned first release is the
most expensive silent failure here.
3. **Never generate a signing key for a product that already has shipped
users.** Ask; import instead. This is irreversible in the way that matters.
4. **Treat the public key as immutable after first ship.** If a task looks like
it needs a key change, stop and surface it — the blast radius is every
installed copy.
5. **Report what you verified and what you did not.** "Feed serves 2.0.1 and
the signature verifies with `minisign`" is a claim; "updates work" is not,
unless you watched an app update itself.
---
## Next Steps
- [[guides/agentic-integration-playbook|The Agentic Integration Playbook]] — the licensing model, wire contracts, and the tier funnel
- [[guides/offline-licensing|Offline Licensing]] — keeping apps working without a connection
- [[api-reference/download-token|Download Tokens]] — license-gated download URLs
---
## Guides
# Guides
In-depth guides for common LicenseSeat use cases.
## Shipping a Licensed App
- [[guides/agentic-integration-playbook|The Agentic Integration Playbook]] — How a coding agent takes a desktop app from unlicensed to licensed and distributable, end to end: the tier funnel, the wire contracts, the client engine, distribution, email capture, and the verification gate
## Distribution & Updates
- [[guides/shipping-desktop-updates|Shipping Desktop Updates]] — Take a desktop app from a build on your laptop to users' apps updating themselves: the publish pipeline, packaging traps that produce uninstallable releases, updater wiring for Tauri/Sparkle/Electron, signing-key rules, and how to verify the chain with tools that are not ours
## Offline & Air-Gapped Licensing
- [[guides/offline-licensing|Offline Licensing]] — Let users work without internet using machine files or, on older SDKs, legacy signed offline tokens
- [[guides/air-gapped-licensing|Air-Gapped Licensing]] — Operator-assisted fingerprint → activate → machine-file workflows for devices that never connect
## Selling Platforms
- [[guides/shopify|Selling software licenses on Shopify]] — Connect a Shopify store: mapped paid orders issue licenses, eligible email delivery sends keys, and refunds revoke order access: the two webhooks to create, where the signing secret lives, how to link products or variants, and how to test it
- [[guides/stripe|Selling software licenses with Stripe Payment Links]] — The webhook endpoint to create in Workbench, the six events, the whsec_ secret, Payment Link IDs versus buy.stripe.com links, subscriptions and renewals, refunds and chargebacks, and how to test in a sandbox
- [[guides/paddle|Selling software licenses with Paddle]] — The notification destination, its secret key, price IDs, passing the buyer email as custom data, refunds and their approval, and testing with Paddle's simulator
- [[guides/gumroad|Selling software licenses on Gumroad]] — The account-wide Ping URL in Settings, Advanced, the product URL to link, what Send test ping does, and manual refunds versus optional API refund notifications
- [[guides/lemonsqueezy|Selling software licenses on Lemon Squeezy]] — The webhook with LicenseSeat's generated secret, Test Mode versus live, order and subscription events, Copy ID for variants, and simulating events
- [[guides/polar|Selling software licenses on Polar.sh]] — The endpoint to add under Settings → Webhooks with the Raw format, the whsec_ secret (both signing generations work), Copy Product ID, subscriptions and renewals, refunds, and testing in Polar's sandbox
## Coming Soon
- License Tiers & Upgrades
- Team & Organization Licenses
- White-Label Email Templates
- [[guides/sales-platform-operations|Operating sales connections]] — Email prerequisites, finite subscription plans, testing isolation and recovery of missing or failed events
---
## Entitlements
Entitlements are feature flags or capabilities tied to a license. They allow you to gate specific features, modules, or tiers within your software without creating separate products or licenses.
## What are Entitlements?
Think of entitlements as fine-grained permissions attached to a license:
- A regular license might include `downloads` only for 1 year
- A "Pro" license might include entitlements for `advanced-export`, `cloud-sync`, and `priority-support`
- A "Team" license might add `multi-user` and `admin-panel` entitlements
- A trial might grant `full-access` with a 14-day expiration
Each entitlement has:
- **Key** — A unique identifier (e.g., `pro-features`, `api-access`, `beta-mode`)
- **Expiration** — Optional expiry date (perpetual if not set)
- **Metadata** — Optional custom data attached to the entitlement
## Setting Up Entitlements
Entitlements are configured on **License Plans** in the LicenseSeat dashboard.
### 1. Create or Edit a License Plan
Go to your product → **License Types** → Edit a plan (or create a new one).
### 2. Add Entitlements
In the "Entitlements" section, add the feature keys you want to grant:
| Feature Slug | When does it expire? | Duration |
|--------------|---------------------|----------|
| `pro-features` | Never | — |
| `beta-access` | Expires after... | 90 days |
| `updates` | With the license | — |
### 3. Expiration Options
Each entitlement can have one of three expiration modes:
| Mode | Description |
|------|-------------|
| **Never** | Perpetual access — the entitlement never expires |
| **With the license** | Expires when the license expires |
| **Expires after...** | Fixed duration from license issuance (e.g., 1 year, 90 days) |
> **Note:** Fixed-duration entitlements start counting from the license's `starts_at` date, not from the first activation.
### 3b. Version Coverage (the `updates` entitlement only)
Beside its time bound, the `updates` entitlement can carry a **version
ceiling** — the plan form's "Up to version" field. `4.0.0` means the license
covers every version *below* 4.0.0 (all of 3.x; the ceiling is exclusive,
and 4.0 betas are not below it). This is how paid major-version upgrades are
sold: a "3.x lifetime" plan is `updates` · Never · up to `4.0.0`.
The ceiling is enforced by the platform everywhere at once: licensed update
feeds, the download link, and **activation/validation in apps that declare
their version** (the official SDKs declare it automatically via telemetry).
A key above its ceiling still *validates* when no version is declared, so
website key-checks and upgrade-discount flows keep working.
Each plan's page has a **Version coverage** card showing what new licenses
will get and what the existing ones actually have, with an
apply-to-existing form: by default it only ever *raises* ceilings
(grandfathering — nobody loses anything), and an explicit "allow reducing"
checkbox imposes the ceiling exactly, including on unbounded licenses — the
paid-discontinuation move. Every change is audited; keys never change. A
single license's ceiling can also be set or cleared from its own page.
**Your app must tell us its version** for the activation/validation gate to
fire — the server compares the version the *app declares* against the
ceiling:
- The official SDKs (Swift, Rust) declare it automatically on every
licensing call via the telemetry envelope's `app_version`, taken from
your app's version (or set explicitly:
`LicenseSeatConfig(appVersion:)` in Swift, `Config.app_version` in Rust).
- Any other client sends `software_version` on `validate` / `activate`.
- The declared version should match what you put in the ceiling field:
semantic versions, compared on their core — `"3.0"` counts as `3.0.0`,
and the ceiling is exclusive, so a `3.0.0` ceiling covers all of 2.x and
nothing from 3.0 onward.
- Requests that declare **no** version still validate (that is what keeps
website key-checks and upgrade-discount flows working), so pair the
server gate with a client-side check for defense in depth:
`entitlement.covers(version:)` (Swift ≥ 0.5.2) /
`entitlement.covers_version(...)` (Rust ≥ 0.6.3).
**No LicenseSeat-managed releases required.** Version coverage works even
if you never upload artifacts or serve update feeds through LicenseSeat:
activation and validation enforce the ceiling purely from the version your
app declares. (If you *do* distribute through LicenseSeat, licensed feeds
and downloads honor it too.)
### 4. Entitlement Keys
Keys must be lowercase alphanumeric with hyphens or underscores:
- `pro-features`
- `api_access`
- `beta2024`
Invalid: `Pro Features`, `api access`, `PRO-FEATURES`
## Checking Entitlements in Your App
All LicenseSeat SDKs provide methods to check entitlements.
### JavaScript
```javascript
// Simple boolean check
if (sdk.hasEntitlement('pro-features')) {
enableProFeatures();
}
// Detailed check with expiration info
const result = sdk.checkEntitlement('beta-access');
if (result.active) {
console.log('Expires:', result.entitlement.expires_at);
} else {
console.log('Reason:', result.reason);
// 'no_license' | 'not_found' | 'expired'
}
```
### Swift
```swift
// Simple check
let status = LicenseSeat.shared.checkEntitlement("pro-features")
if status.active {
enableProFeatures()
}
// SwiftUI property wrapper
@EntitlementState("pro-features") private var hasPro
var body: some View {
if hasPro {
ProFeaturesView()
}
}
// Reactive publisher
LicenseSeat.shared.entitlementPublisher(for: "beta-access")
.sink { status in
updateUI(for: status)
}
```
### C#
```csharp
// Simple boolean check
if (LicenseSeat.HasEntitlement("pro-features"))
{
EnableProFeatures();
}
// Detailed check
var status = LicenseSeat.Entitlement("beta-access");
if (status.Active)
{
Console.WriteLine($"Expires: {status.ExpiresAt}");
}
else
{
switch (status.Reason)
{
case EntitlementInactiveReason.Expired:
ShowRenewalPrompt();
break;
case EntitlementInactiveReason.NotFound:
ShowUpgradePrompt();
break;
}
}
```
### C++
```cpp
// Simple boolean check
if (client.has_entitlement("pro-features")) {
enable_pro_features();
}
// Detailed check with expiration and metadata
auto status = client.check_entitlement("beta-access");
if (status.active) {
std::cout << "Active until: " << status.expires_at.value_or(0) << "\n";
// Access metadata if needed
if (status.entitlement) {
for (const auto& [key, value] : status.entitlement->metadata) {
std::cout << key << ": " << value << "\n";
}
}
} else {
std::cout << "Inactive: " << status.reason << "\n";
// Reasons: "no_license", "not_found", "expired"
}
```
## API Response
Entitlements are returned in all license validation responses:
```json
{
"valid": true,
"license": {
"key": "XXXX-XXXX-XXXX-XXXX",
"status": "active",
"active_entitlements": [
{
"key": "pro-features",
"expires_at": null,
"metadata": {}
},
{
"key": "beta-access",
"expires_at": "2024-12-31T23:59:59Z",
"metadata": { "beta_version": "2.0" }
}
]
}
}
```
## Offline Support
Entitlements are included in offline machine files, allowing you to check them without network access:
```json
{
"license": {
"key": "XXXX-XXXX-XXXX-XXXX",
"entitlements": [
{ "key": "pro-features", "expires_at": null },
{ "key": "beta-access", "expires_at": "2024-12-31T23:59:59Z" }
]
}
}
```
For SDKs that already support machine files, entitlements are read from the cached machine file when the network is unavailable. Older SDKs may still use signed offline tokens as a legacy compatibility path.
## Granting Entitlements to Individual Licenses
Beyond plan-level entitlements, you can grant additional entitlements to specific licenses:
1. Go to the license detail page in the dashboard
2. In the "Entitlements" section, click "Grant new entitlement"
3. Enter the feature key and expiration
This is useful for:
- Granting beta access to specific customers
- Extending a feature for a loyal customer
- Adding promotional features
## Best Practices
### Use Descriptive Keys
```
✓ pro-export, cloud-sync, api-access
✗ feat1, pro, x
```
### Check Entitlements, Not Plans
Instead of checking the plan name:
```javascript
// ✗ Fragile - breaks if you rename plans
if (license.plan_key === 'pro') { ... }
```
Check for specific capabilities:
```javascript
// ✓ Flexible - works regardless of plan structure
if (sdk.hasEntitlement('advanced-export')) { ... }
```
### Handle Missing Entitlements Gracefully
```javascript
const result = sdk.checkEntitlement('new-feature');
if (!result.active && result.reason === 'not_found') {
// Feature not in their plan - show upgrade prompt
showUpgradeModal();
}
```
### Use Expiring Entitlements for Trials
Instead of separate trial licenses, use time-limited entitlements:
| Plan | Entitlement | Expiration |
|------|-------------|------------|
| Trial | `full-access` | 14 days |
| Pro | `full-access` | Never |
This way, trial users automatically lose access after 14 days without requiring license revocation.
## Next Steps
- [[sdks/javascript|JavaScript SDK]] — Full entitlement API reference
- [[sdks/swift|Swift SDK]] — SwiftUI integration with `@EntitlementState`
- [[sdks/csharp|C# SDK]] — Events and reactive patterns
- [[sdks/cpp|C++ SDK]] — Thread-safe entitlement checking
---
## Create a license
Create a license after a purchase in your own store or another payment provider.
Your backend makes this call; never put its secret API key in a browser, desktop
app, game or plugin.
1. In your product's **Plans** page, create or select the license plan. Copy its
**Key** (for example `pro-annual`), not its display name or record UUID. The plan
supplies license duration, seat count, licensing mode and entitlements.
2. Create a secret (`sk_`) key with the `licenses:create` scope in API Keys settings.
Keep it in your backend's secrets. Publishable (`pk_`) keys cannot issue licenses.
3. Make the request using your product slug and that plan key:
```sh
curl --fail-with-body https://licenseseat.com/api/v1/products/your-product/licenses \
-H "Authorization: Bearer $LICENSESEAT_SECRET_KEY" \
-H "Content-Type: application/json" \
--data '{"plan_key":"pro-annual","metadata":{"order_id":"your-order-id"}}'
```
A successful request returns HTTP 201 and a license object. Its `key` is the
license key to store and deliver to your buyer. A lifetime plan can return
`expires_at: null`; unlimited seats can return `seat_limit: null`.
Verify the purchase on your backend before issuing. Deduplicate payment webhook
retries using your order ID and retain the returned license key. Adding metadata
alone does not make this endpoint idempotent: two successful requests can create
two licenses. If a response is lost, reconcile the purchase before retrying blindly.
The minimal request does not need owner parameters. Optional `owner_type` and
`owner_id` associate an existing customer record; they do not create a customer
or accept an email address. LicenseSeat.com accepts `owner_type: "Customer"` and
an existing Customer UUID in your organization. Other hosts of the open-source
engine can configure their own owner resolver.
For revocation, `POST /api/v1/products/{slug}/licenses/revoke` takes `license_key`
and an optional `reason`, using a secret key with `licenses:revoke`. It disables
server-side usage. A disconnected device cannot learn of revocation until it
reconnects or its permitted offline artifact expires.
[Full request and response schemas](/api/) include both administrative endpoints.
These operations do not belong in distributed client SDKs.
---
## Generate Download Token
# Generate Download Token
Creates a time-limited, cryptographically signed token for secure release downloads.
```
POST /api/v1/products/{slug}/releases/{version}/download_token
```
> [Try it in the Interactive API Docs →](/api/)
## When to Use
- Gating software downloads to valid license holders
- Implementing secure auto-update functionality
- Preventing unauthorized distribution
## Path Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `slug` | Yes | Product slug (e.g., `my-app`) |
| `version` | Yes | Release version (e.g., `2.1.0`) |
## Request Body
| Parameter | Required | Description |
|-----------|----------|-------------|
| `license_key` | Yes | License key to authorize the download |
| `platform` | No | Target platform (e.g., `macos`, `windows`, `linux`) |
## Example Request
```bash
curl -X POST https://licenseseat.com/api/v1/products/my-app/releases/2.1.0/download_token \
-H "Authorization: Bearer pk_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"license_key": "LS-ABCD-1234-EFGH-5678",
"platform": "macos"
}'
```
## Response
```json
{
"object": "download_token",
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-01-21T12:05:00Z"
}
```
## Response Fields
| Field | Description |
|-------|-------------|
| `token` | Signed JWT token for download authorization |
| `expires_at` | Token expiration timestamp (typically 5 minutes) |
## Error Codes
| Code | HTTP | Meaning |
|------|------|---------|
| `license_not_found` | 404 | License key doesn't exist |
| `release_not_found` | 404 | Release version doesn't exist |
| `expired` | 422 | License has expired |
| `revoked` | 422 | License has been revoked |
| `download_token_not_configured` | 400 | Download tokens not enabled for this product |
## Using the Download Token
Your download server should verify the token before serving files:
1. Extract the token from the request (query param or header)
2. Verify the Ed25519 signature using your signing key
3. Check token hasn't expired (`exp` claim)
4. Verify product and version match the requested file
5. Serve the file if valid
### Client-Side Usage
```javascript
// Get download token
const response = await fetch(
'/api/v1/products/my-app/releases/2.1.0/download_token',
{
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_xxxxxxxx',
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_key: 'LS-ABCD-1234-EFGH-5678',
platform: 'macos'
})
}
);
const { token, expires_at } = await response.json();
// Use token to download
const downloadUrl = `https://downloads.example.com/my-app/2.1.0/installer.dmg?token=${token}`;
window.location.href = downloadUrl;
```
---
## API Quickstart
Introduction to the LicenseSeat API.
> [!IMPORTANT]
**Full API Docs** — To see the full LicenseSeat API docs, together with the LicenseSeat OpenAPI spec, head to the [LicenseSeat API Reference page](/api/). It also has an interactive experience with request testing, error codes, full schemas, and live examples.
## Essentials
- **Base URL:** `https://licenseseat.com/api/v1`
- **Auth:** Bearer token in `Authorization` header
- **Format:** JSON request/response bodies
- **Errors:** Structured format with `error.code` and `error.message`
All license operations are **product-scoped**. The product slug is part of the
URL path; the license key belongs in the bounded JSON request body so it does
not leak through URL logs, browser history, proxy traces, or monitoring tools:
```bash
curl -X POST https://licenseseat.com/api/v1/products/my-app/licenses/validate \
-H "Authorization: Bearer pk_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"license_key":"XXXX-XXXX-XXXX-XXXX","fingerprint":"device-unique-id"}'
```
`fingerprint` is the canonical device-binding field. `device_id` remains accepted as a legacy alias for compatibility with older SDKs and integrations.
## Create licenses from your backend
For custom stores and payment integrations, see [[api-reference/create-license|Create a license]].
This is a backend operation using a secret key with `licenses:create`; a publishable
key embedded in an app cannot issue licenses. The guide explains `plan_key`, request
format, delivery of the returned key, and retry handling.
## Response Format
All responses include an `object` field identifying the type:
```json
{
"object": "validation_result",
"valid": true,
"license": { ... },
"activation": { ... }
}
```
## Rate Limits
| Plan | Requests/min | Requests/day |
|------------|--------------|--------------|
| Free | 60 | 1,000 |
| Pro | 300 | 50,000 |
| Enterprise | Unlimited | Unlimited |
Rate limit headers included in all responses: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
---
## Get Signing Key
# Get Signing Key
Retrieves an Ed25519 public key for verifying offline machine-file signatures and legacy offline-token signatures.
```
GET /api/v1/signing_keys/{key_id}
```
> **Note:** This endpoint does not require authentication.
> [Try it in the Interactive API Docs →](/api/)
## When to Use
- Fetching verification keys for machine files
- Fetching verification keys for legacy offline tokens
- Supporting key rotation
- Dynamically loading unknown key IDs
## Path Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `key_id` | Yes | Key ID from a machine file envelope `kid` or a legacy offline token `signature.key_id` / `token.kid` |
## Example Request
```bash
curl https://licenseseat.com/api/v1/signing_keys/org-xxx-offline-v1
```
No authentication required.
## Response
```json
{
"object": "signing_key",
"key_id": "org-xxx-offline-v1",
"algorithm": "Ed25519",
"public_key": "base64_encoded_32_byte_public_key=",
"created_at": null,
"status": "active"
}
```
## Response Fields
| Field | Description |
|-------|-------------|
| `key_id` | Unique key identifier |
| `algorithm` | Signature algorithm (`Ed25519`) |
| `public_key` | Standard padded Base64 encoding of the raw 32-byte Ed25519 public key |
| `created_at` | Key creation time when supplied by the host; currently `null` in the Rails engine response |
| `status` | Key status; currently `active` for a successfully resolved key |
## Error Codes
| Code | HTTP | Meaning |
|------|------|---------|
| `signing_key_not_found` | 404 | Key ID doesn't exist |
| `resolver_not_configured` | 500 | The host application did not configure a public-key resolver |
| `resolver_data_invalid` | 500 | The resolver returned something other than a raw 32-byte key |
## Key Rotation
The token or machine-file `kid` is the routing key for rotation. The current
LicenseSeat SaaS resolver exposes only the organization's exact configured key
ID. If you implement rotation in another host application, its resolver must
retain every retired public key for at least as long as artifacts signed by that
key can remain valid.
Do not rotate key bytes in place while reusing a `kid`: SDKs cache public keys by
ID, and existing artifacts would become unverifiable. Issue a new unique key ID,
sign new artifacts with it, and keep the old public key resolvable through the
retirement window.
## Best Practices
### Embed Known Keys
For performance and offline support, embed known public keys in your app:
```javascript
const KNOWN_KEYS = {
'org-xxx-offline-v1': 'base64_encoded_key_1=',
'org-xxx-offline-v2': 'base64_encoded_key_2=',
};
async function getPublicKey(keyId) {
// Use embedded key if known
if (KNOWN_KEYS[keyId]) return KNOWN_KEYS[keyId];
// Fetch unknown keys from API
const response = await fetch(`/api/v1/signing_keys/${encodeURIComponent(keyId)}`);
const data = await response.json();
return data.public_key;
}
```
### Security
- **Validate key length** — Decoded key must be exactly 32 bytes
- **Require exact key IDs** — Do not accept aliases that merely contain a real organization ID
- **Always verify signatures** — Even with embedded keys
- **Handle new key IDs** — Be prepared for key rotation
- **Use constant-time comparison** — Prevent timing attacks
---
## Telemetry
# Telemetry
Supported licensing requests can include an optional `telemetry` object with device and app information. LicenseSeat uses this enrichment to power per-product analytics: DAU/MAU, version adoption, platform distribution, geographic breakdown, and stale device detection.
Official SDKs may attach this enrichment automatically, according to their version and configuration. If you're building a custom integration, this page describes the telemetry format and what each field is used for.
The optional telemetry object is distinct from the data required to perform licensing. Successful licensing requests still create an activity footprint containing the event, license/product context, installation fingerprint when present, request time, and source IP even when optional telemetry is disabled.
## The Telemetry Object
Include a `telemetry` key in the JSON body of any POST request:
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"fingerprint": "mac-a1b2c3d4-e5f6g7h8",
"telemetry": {
"sdk_name": "swift",
"sdk_version": "0.4.2",
"os_name": "macOS",
"os_version": "15.2.0",
"platform": "native",
"device_model": "MacBookPro18,1",
"app_version": "2.1.0",
"app_build": "42",
"device_type": "desktop",
"architecture": "arm64",
"cpu_cores": 10,
"memory_gb": 32,
"locale": "en_US",
"language": "en",
"timezone": "America/New_York",
"screen_resolution": "3456x2234",
"display_scale": 2.0
}
}
```
## Fields
### Promoted Columns
These fields are stored as dedicated database columns and are directly queryable in the analytics dashboard:
| Field | Type | Description | Used for |
|-------|------|-------------|----------|
| `sdk_name` | String | SDK identifier such as `swift`, `js`, `cpp`, `csharp`, or `rust` | SDK distribution |
| `sdk_version` | String | Version of the LicenseSeat SDK | SDK adoption tracking |
| `os_name` | String | Operating system name (`macOS`, `iOS`, `Windows`, `Linux`, `Android`) | Platform distribution |
| `os_version` | String | OS version (e.g., `15.2.0`) | OS distribution |
| `platform` | String | Runtime environment: `native`, `node`, `browser`, `electron`, `react-native`, `deno`, `bun`, `unity` | Platform analytics |
| `device_model` | String | SDK-reported model value; semantics vary by SDK and can include a machine name | Device analytics |
| `app_version` | String | Host app version | Version adoption charts |
| `app_build` | String | Host app build number | Build tracking |
| `device_type` | String | Device form factor: `phone`, `tablet`, `desktop`, `watch`, `tv`, `server`, `unknown` | Device type distribution |
| `architecture` | String | CPU architecture: `arm64`, `x64`, `x86` | Architecture distribution |
| `cpu_cores` | Integer | Number of CPU cores | Hardware segmentation |
| `memory_gb` | Integer | Total RAM in GB (rounded) | Hardware segmentation |
| `locale` | String | Full locale (e.g., `en_US`, `pt_BR`) | Localization insights |
| `language` | String | 2-letter ISO 639-1 code (e.g., `en`, `pt`, `es`) -- extracted from locale | Language distribution |
### Additional Metadata
These fields are stored in the JSONB metadata column:
| Field | Type | Description | SDKs |
|-------|------|-------------|------|
| `screen_resolution` | String | Screen resolution as `WIDTHxHEIGHT` | Swift, JS (browser), C++ |
| `display_scale` | Number | Display pixel ratio (1.0, 2.0, 3.0) | Swift, JS (browser) |
| `browser_name` | String | Browser name (Chrome, Safari, Firefox, Edge) | JS (browser only) |
| `browser_version` | String | Browser version | JS (browser only) |
| `runtime_version` | String | Runtime version (e.g., `.NET 9.0.0`, `Node 20.11.0`) | JS, C# |
| `timezone` | String | IANA timezone (e.g., `America/New_York`) | All SDKs |
All fields are optional. Send what you have -- partial telemetry is better than none.
## Which Endpoints Accept Telemetry
Telemetry is captured on these endpoints:
| Endpoint | Event type |
|----------|------------|
| `activate` | `activation` |
| `deactivate` | `deactivation` |
| `validate` | `validation` |
| `heartbeat` | `heartbeat` |
| `machine-file` checkout | `machine_file_checkout` |
Every successful request to these endpoints creates a footprint with the telemetry data, the request IP (for geolocation), and the event type.
See the [[api-reference/index|API quickstart and interactive reference]] for the complete endpoint contracts.
## How the SDKs Handle It
Consult the SDK-specific configuration. Several official SDKs enable telemetry by default, but availability and opt-out controls differ by SDK version.
### Swift SDK
Swift 0.4.2 includes telemetry by default on supported licensing POST requests.
Disable the optional object explicitly when the host application does not need
LicenseSeat analytics:
```swift
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product"
) { config in
config.telemetryEnabled = false
}
// When enabled, the optional telemetry object contains:
// What gets collected:
// - sdk_name: "swift"
// - sdk_version: from LicenseSeatConfig.sdkVersion
// - os_name: "macOS", "iOS", "tvOS", "watchOS", "visionOS"
// - os_version: from ProcessInfo.operatingSystemVersion
// - platform: "native"
// - device_model: from sysctlbyname("hw.model")
// - app_version: from CFBundleShortVersionString
// - app_build: from CFBundleVersion
// - device_type: "desktop", "phone", "tablet", "watch", "tv", "headset"
// - architecture: "arm64" or "x64"
// - cpu_cores: from ProcessInfo.processorCount
// - memory_gb: from ProcessInfo.physicalMemory (rounded)
// - locale: from Locale.current.identifier
// - language: 2-letter code from locale
// - timezone: from TimeZone.current.identifier
// - screen_resolution: native pixel resolution
// - display_scale: backingScaleFactor / UIScreen.scale
```
The Swift SDK also creates a random app-scoped installation identifier and persists it in Keychain on Apple platforms. It sends that required seat-binding value as the top-level `fingerprint`, independently of `telemetryEnabled`. Legacy clients may still use the `device_id` field name, but `fingerprint` is the canonical wire term.
### Custom Integrations
If you're building a custom SDK or integration, collect whatever fields are available on your platform and include them in the `telemetry` object:
```python
import platform
import requests
requests.post(
"https://licenseseat.com/api/v1/products/my-app/licenses/validate",
headers={"Authorization": "Bearer pk_live_xxx"},
json={
"license_key": "XXXX-XXXX-XXXX-XXXX",
"fingerprint": get_device_fingerprint(),
"telemetry": {
"sdk_name": "python",
"sdk_version": "0.1.0",
"os_name": platform.system(),
"os_version": platform.release(),
"platform": "native",
"device_type": "desktop",
"architecture": platform.machine(),
"app_version": "1.0.0"
}
}
)
```
```csharp
var body = new {
license_key = "XXXX-XXXX-XXXX-XXXX",
fingerprint = GetDeviceFingerprint(),
telemetry = new {
sdk_name = "csharp",
sdk_version = "0.5.0",
os_name = Environment.OSVersion.Platform.ToString(),
os_version = Environment.OSVersion.Version.ToString(),
platform = "native",
device_type = "desktop",
architecture = RuntimeInformation.ProcessArchitecture.ToString(),
app_version = Assembly.GetExecutingAssembly().GetName().Version.ToString()
}
};
```
## What Powers the Analytics Dashboard
The telemetry data feeds directly into your product's analytics dashboard in LicenseSeat. The dashboard offers **7, 30, and 90 day** time range selectors.
### KPI Cards
- **DAU** — Daily Active Users (unique devices today)
- **MAU** — Monthly Active Users (unique devices this month)
- **Unique Devices** — Over selected time range
- **Total Events** — All telemetry events in range
### Charts & Distributions
| Section | Metrics | Based on |
|---------|---------|----------|
| **Daily Active Devices** | Bar chart of unique devices per day | `fingerprint` counts |
| **Geographic Distribution** | World map + country breakdown | IP geolocation (automatic) |
| **Version Adoption** | Donut + stacked area chart | `app_version` |
| **Runtime Environment** | OS versions, platforms, device types | `os_name`, `os_version`, `platform`, `device_type` |
| **Hardware** | Architecture, CPU cores histogram, memory histogram | `architecture`, `cpu_cores`, `memory_gb` |
### LicenseSeat Section
| Metric | Description |
|--------|-------------|
| **Seats** | Utilization percentage (used/total across all licenses) |
| **Stale Devices** | Devices with no heartbeat in **7+ days** |
| **SDK Versions** | Distribution of `sdk_version` values |
| **SDK Platforms** | Distribution of `sdk_name` (swift, js, cpp, csharp, rust) |
## Geolocation
In addition to the telemetry fields you send, LicenseSeat automatically resolves geolocation from the request IP address. This provides country, city, region, coordinates, and timezone -- no extra work needed from the SDK.
## Privacy
You can omit the optional telemetry object; the API's licensing decisions work the same without that enrichment. This does not make a licensing request anonymous: license/product identifiers, an installation fingerprint when applicable, event timing, and the server-visible source IP are still required or recorded for service operation, abuse prevention, and licensing analytics.
Persistent identifiers, IP-derived location, request activity, and diagnostic attributes can be personal data or data linked to a user under platform policy and privacy law even when they do not contain a person's name. Integrators remain responsible for an accurate privacy notice, lawful basis/consent where required, retention and deletion behavior, and their complete App Store or marketplace disclosures. Disabling optional telemetry alone does not establish GDPR or other legal compliance.
---
## Selling software licenses on Gumroad
# Selling software licenses on Gumroad
Connect a Gumroad account to LicenseSeat and sales of linked products issue license keys; eligible email delivery sends them to the buyer. Membership renewals extend the license. No app to install and no API key to create: the connection uses Gumroad's built-in **Ping**, a one-line setting in your Gumroad account.
One thing to know up front: Gumroad's Ping reports sales only. **Refunds are not sent to LicenseSeat**, so a refund does not revoke the license by itself (see [Refunds](#refunds) below).
Setup takes about three minutes.
## Before you start
Link the product to a license plan before testing. Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. Issuance can succeed without sending an email. Use a separate test organization when you need complete separation: connections in one organization share product mappings by provider.
Read [[guides/sales-platform-operations|Email delivery, subscription expiry and event recovery]] for the common prerequisites and recovery procedure. Setup instructions remain available on the connection page after saving.
## 1. Create the connection in LicenseSeat
In the LicenseSeat dashboard sidebar open **Connected platforms**, click **Connect Your First Platform** (or **Connect Another**), choose **Gumroad**, and give the connection a name. The page shows your **webhook URL**. Copy it. Keep it private: Gumroad does not sign its pings, so the random part of this URL is the only thing stopping someone else from issuing licenses for your products.
## 2. Paste the URL into Gumroad
In Gumroad go to **Settings → Advanced** (gumroad.com/settings/advanced) and scroll to the **Ping** section:
| Field | Value |
|---|---|
| Ping endpoint | your LicenseSeat webhook URL from step 1 |
Then click **Update settings** at the top of the page.
Gumroad has one Ping URL per account, so this URL now receives every sale from every product you sell. LicenseSeat only acts on the products you link in step 3 and ignores the rest.
## 3. Link your products
Tell LicenseSeat which license plan to issue for each Gumroad product. On the connection page click **Link Product**, pick the plan, and paste the product's Gumroad URL.
To get the URL, open the product in Gumroad, click **Share**, then **Copy URL**. It looks like `https://you.gumroad.com/l/your-product`. Pasting the whole URL works; LicenseSeat keeps the part after `/l/`.
Two Gumroad details worth knowing:
- Every product has a default short link (the one ending in a code such as `/l/QaSmj`) that keeps working even after you give the product a custom URL. Either form works here.
- If you later **rename** a custom URL in Gumroad, the old one stops matching (Gumroad does not redirect old custom URLs). Update the linked product in LicenseSeat too.
## 4. Test it
Three levels, from cheapest to most complete:
1. **Reachability.** In Gumroad's Ping section click **Send test ping to URL**. Gumroad re-sends your most recent sale with `test=true` and tells you the HTTP status LicenseSeat answered with. The LicenseSeat connection page shows *Last webhook received*. No license is issued: LicenseSeat deliberately ignores test pings. If your account has no sales yet, Gumroad says "There are no sales on your account to test with. Please make a test purchase and try again."
2. **Test purchase.** Stay logged in to Gumroad, open your product page, click **I want this!**, and at checkout pay with the **Test card** that Gumroad shows to the product's owner. You are not charged. The ping arrives marked `test=true`, so again LicenseSeat updates the connection’s received timestamp but creates no payment event or license.
3. **Real rehearsal.** Create a 100% discount code in Gumroad and buy the product from a different email address. That is a real sale: the license is issued and emailed to that address. This is also how Gumroad itself recommends testing sale-triggered automations. Do not buy your own product with a real card; Gumroad warns that this looks like card testing and can get the account suspended.
## What happens on each event
| What happens in Gumroad | LicenseSeat |
|---|---|
| A product is bought (one-time) | Issues a license for the linked product, owned by the buyer's email, and emails the key. Quantity follows the purchase (multi-seat licenses on Gumroad arrive as a quantity). |
| A membership is started, including a free-trial signup | Issues the license the same way. |
| A membership renews (each recurring charge) | Extends the existing license issued for that membership. |
| A membership is cancelled or ends | Nothing arrives. The license is simply not extended again. |
| A sale is refunded | Nothing arrives. See [Refunds](#refunds). |
| Test ping or logged-in test purchase | Connection received timestamp updated; no payment-event row or license. |
| A sale of a product you have not linked | Ignored. |
## Refunds
Gumroad's Ping only carries sales. When you refund a sale in Gumroad, nothing is sent to your Ping URL, so LicenseSeat cannot revoke the license automatically. After refunding, open the license in LicenseSeat and revoke it there.
### Optional: automatic revocation through Gumroad's API
Gumroad does offer refund notifications through its API, as a "resource subscription". Setting one up takes an access token:
1. In Gumroad go to **Settings → Advanced**, scroll to **Applications**, and click **Create application** (any name; for the Redirect URI Gumroad's help says to enter `http://127.0.0.1` when you only need a token for your own account). Then click **Generate access token**. Treat the token like a password.
2. Subscribe your LicenseSeat webhook URL to the `refund` resource:
```
curl https://api.gumroad.com/v2/resource_subscriptions \
-d "access_token=ACCESS_TOKEN" \
-d "resource_name=refund" \
-d "post_url=YOUR_LICENSESEAT_WEBHOOK_URL" \
-X PUT
```
or, with Gumroad's CLI: `gumroad webhooks create --resource refund --url YOUR_LICENSESEAT_WEBHOOK_URL`.
From then on a full refund in Gumroad revokes the license and emails the buyer. Partial refunds do not revoke: Gumroad keeps the buyer's access after a partial refund, and so does LicenseSeat. The API subscription is tied to the token; if you delete the application or revoke the token, refund notifications stop.
## Good to know
- **Existing perpetual mappings.** Subscription purchases still issue on a perpetual plan. Events that identify a subscription show a warning; cancellation does not revoke lifetime access. Existing perpetual licenses remain perpetual on renewal. Choose a finite plan for access that should expire, and review [[guides/sales-platform-operations|subscription access and recovery]] before changing existing mappings.
- **Membership expiry and trials.** Use a finite license plan matching the billing interval. Initial signup grants that duration, including for a free trial; it does not synchronize Gumroad’s trial end. Renewals use the sale timestamp plus the plan duration. Cancellation sends no Ping and does not revoke; access ends at the stored expiry without another renewal.
- **Fully refunded delayed sales.** If an old sale is delivered with `refunded=true`, LicenseSeat treats it as a reversal instead of granting fresh access. This does not make basic Ping a refund subscription.
- **Existing product mappings.** Legacy mixed-case and custom-domain URLs continue matching without a database rewrite. If multiple legacy mappings normalize to the same identifier, fulfillment fails visibly; remove the ambiguity and retry instead of letting an arbitrary plan win.
- **No signing secret.** Gumroad has no webhook signature. LicenseSeat identifies your account by the long random token in the webhook URL, which is why that URL must stay private. Use the HTTPS URL LicenseSeat gives you; Gumroad recommends HTTPS endpoints.
- **Retries.** Gumroad delivers at least once and retries a failed delivery with increasing delays (consult the current Ping reference for its delivery schedule), only for HTTP 499/500/502/503/504, and gives the endpoint 5 seconds to answer. LicenseSeat answers immediately and processes in the background; a re-delivered sale is recognised by its `sale_id`, so a sale never issues twice.
- **Amounts and currency.** Gumroad reports the price paid in USD cents regardless of the product's listed currency; that is what LicenseSeat records on the event.
- **Multi-seat licenses.** If the Gumroad product lets buyers choose a number of seats, the ping's quantity is the seat count and LicenseSeat issues that many licenses.
- **Gifts.** A gifted product pings once, for the recipient's purchase; the license goes to the recipient's email.
## Troubleshooting
- **The connection stays "Incomplete".** No product is linked yet. Gumroad needs no secret, so linking a product is the only requirement.
- **The connection stays "Awaiting webhook" after a test ping or test purchase.** Expected: those carry `test=true` and update the received timestamp without creating a payment event. The status flips on the first real sale of a linked product (a 100% discount code from another email counts).
- **The event shows as "ignored" with "no active mapping".** The product URL you linked does not match what Gumroad sent. Open the product in Gumroad, click **Share → Copy URL**, and compare; if you renamed the custom URL, relink with the new one (or use the default short link, which never changes).
- **Gumroad's test ping reports a non-2xx status.** Gumroad shows the status it received. A 404 means the URL is wrong or the connection was deleted; a 400 means the request was malformed (Gumroad itself never sends one, so something is rewriting the request between Gumroad and LicenseSeat).
- **A refund did not revoke.** Basic Ping does not send refunds; revoke manually or configure the API subscription above. With an API subscription, verify a full refund delivered `resource_name=refund` and `refunded=true`, then inspect/retry the LicenseSeat event.
## References
These are Gumroad's own pages. If a label or menu in Gumroad ever differs from this guide, they are the source of truth:
- [Gumroad Ping](https://gumroad.com/ping) — the mechanism this integration uses: the HTTP POST, the full parameter table (`sale_id`, `product_permalink`, `short_product_id`, `email`, `price`, `quantity`, `subscription_id`, `is_recurring_charge`, `refunded`, `test`, `resource_name`), delivery guarantees, retries and the 5-second timeout.
- [Account settings](https://gumroad.com/help/article/67-the-settings-menu) — the Settings page, the "Update Settings" button, and what the **Advanced** tab holds (custom domain, blocked emails, Ping, API applications).
- [Customize and share your product's URL](https://gumroad.com/help/article/136-find-your-products-url) — **Share → Copy URL**, and how to change the URL suffix.
- [Custom product URLs](https://gumroad.com/help/article/81-custom-product-urls) — the `username.gumroad.com/l/QaSmj` → `/l/poetryclub` rename, the fact that old custom URLs do not redirect, and that the default link keeps working.
- [Testing a purchase](https://gumroad.com/help/article/62-testing-a-purchase) — the logged-in **Test card** flow and the warning against paying yourself with a real card.
- [Send automated emails with Workflows](https://gumroad.com/help/article/131-using-workflows-to-send-automated-updates) — Gumroad's own advice to rehearse sale-triggered automations with a 100% discount code from another email.
- [Issuing a refund](https://gumroad.com/help/article/47-how-to-refund-a-customer) — full vs partial refunds; buyers keep access after a partial refund.
- [Selling memberships and subscriptions](https://gumroad.com/help/article/82-membership-products) — how memberships, free trials and recurring charges work.
- [Gumroad API: resource subscriptions](https://gumroad.com/api#resource-subscriptions) — the API-only notifications (`sale`, `refund`, `dispute`, `dispute_won`, `cancellation`, `subscription_updated`, `subscription_ended`, `subscription_restarted`).
- [Create an application for the API](https://gumroad.com/help/article/280-create-application-api) — **Create application** and **Generate access token** under Settings → Advanced.
- [Gumroad CLI](https://github.com/antiwork/gumroad-cli) — `gumroad webhooks create --resource --url `.
- [Gumroad Ping payload implementation (pinned)](https://github.com/antiwork/gumroad/blob/1d7b07de3bff3adc5982792c8669f34d7b64ed2f/app/modules/purchase/ping_notification.rb) — exact payload and refunded/test flags used for this audit.
---
## Selling software licenses on Lemon Squeezy
# Selling software licenses on Lemon Squeezy
Connect a Lemon Squeezy store to LicenseSeat and orders for linked variants issue licenses; eligible email delivery sends them to the buyer. A refund revokes it. Subscriptions are supported: each renewal extends the license, a refunded renewal revokes it. Nothing to install: the connection is a webhook you create in your Lemon Squeezy dashboard.
Setup takes about five minutes.
## Before you start
Link the product to a license plan before testing. Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. Issuance can succeed without sending an email. Use a separate test organization when you need complete separation: connections in one organization share product mappings by provider.
Read [[guides/sales-platform-operations|Email delivery, subscription expiry and event recovery]] for the common prerequisites and recovery procedure. Setup instructions remain available on the connection page after saving.
## 1. Create the connection in LicenseSeat
In the LicenseSeat dashboard sidebar open **Connected platforms**, click **Connect Your First Platform** (or **Connect Another**), choose **Lemon Squeezy**, and give the connection a name. The page shows two values:
- your **webhook URL**
- your **signing secret**, which LicenseSeat generated for you
Copy both. Keep them private: anyone holding the secret could forge orders and mint licenses for your products.
## 2. Turn Test Mode off
Look at the bottom of Lemon Squeezy's left sidebar for the **Test Mode** switch. Make sure it is **off** before the next step.
Lemon Squeezy keeps test-mode and live-mode webhooks apart: a webhook created while Test Mode is on is only triggered by test orders, and one created with it off is only triggered by real orders. New stores start in Test Mode, so this is easy to miss. If you want a rehearsal as well, come back later and create a second webhook with Test Mode on (see step 5).
## 3. Create the webhook in Lemon Squeezy
Go to **Settings → Webhooks** and click the **+** button at the top right. Fill in:
| Field | Value |
|---|---|
| Callback URL | your LicenseSeat webhook URL from step 1 |
| Signing secret | your LicenseSeat signing secret from step 1 |
| Events | tick **order_created** and **order_refunded** |
If you sell subscriptions, also tick **subscription_created**, **subscription_payment_success** and **subscription_payment_refunded**.
Save. Lemon Squeezy accepts any secret of 6 to 40 characters; the one LicenseSeat generated is 40, so it fits as is.
*order_created* fires when an order is placed and that is what issues the license. *order_refunded* fires when you refund the order, fully or partially, and that revokes it. For subscriptions, *subscription_payment_success* arrives on every renewal and extends the license; *subscription_payment_refunded* revokes it. Every other event is ignored, so ticking more does no harm and unticking these does.
## 4. Link your products
Tell LicenseSeat which license plan to issue for each Lemon Squeezy variant. On the connection page click **Link Product**, pick the plan, and enter the **Variant ID**.
To find it, open **Store → Products** in Lemon Squeezy, open the product's menu and choose **Copy ID**. If the product has several variants (say a personal and a studio edition) and each should issue a different license, use the variant's own **Copy ID** instead. A product with a single price still has exactly one variant, which Lemon Squeezy calls *Default*, and Copy ID gives you its number.
The Variant ID is a plain number, for example `123456`. A long code with dashes taken from a checkout link is the variant's checkout slug, not its ID, and will not match. Test Mode products have their own IDs, separate from live ones: link the live ID for real sales.
## 5. Test it
Lemon Squeezy can only simulate events for Test Mode orders, and Test Mode webhooks are separate from live ones, so a rehearsal needs its own setup:
1. Turn **Test Mode** on.
2. Create a second webhook exactly like step 3 (same URL, same secret, same events).
3. Publish a product in Test Mode (test-mode products are separate copies) and link its Variant ID in LicenseSeat too.
4. Open the product's **Share** or **Preview** checkout and buy it with card number `4242 4242 4242 4242`, any future expiry and any CVC.
Within a few seconds the LicenseSeat connection shows **Active** and a license appears under your product, owned by the email you typed at checkout.
To replay an event without buying again, open the test order in Lemon Squeezy, click **Simulate event**, pick `order_created` or `order_refunded` from the dropdown and click **Trigger event**. On a test subscription the same button offers `subscription_created`, `subscription_cancelled`, `subscription_expired` and the rest; renewal events (`subscription_payment_*`) can only be simulated once a real renewal has happened, so use a daily billing interval on the test product if you need them.
Every delivery, live or test, is listed under **Recent deliveries** on Settings → Webhooks, with the response code LicenseSeat returned and a **Resend** button.
Revoke the test licenses afterwards, or refund the test order.
## What happens on each event
| Lemon Squeezy event | LicenseSeat |
|---|---|
| `order_created` | Issues a license for the order's first item if its variant is linked, owned by the buyer's email, and emails the key. One license per order. |
| `order_refunded` | Revokes the license issued for that order. Partial refunds revoke too. |
| `subscription_created` | Sent together with `order_created` on the first payment of a subscription. LicenseSeat issues one license, not two, and remembers the subscription. |
| `subscription_payment_success` | On a renewal (`billing_reason` is `renewal`), extends the license issued when the subscription started. The initial payment is ignored here because `order_created` already handled it. |
| `subscription_payment_refunded` | Revokes the license attached to that subscription. |
| `subscription_cancelled`, `subscription_expired`, `subscription_updated`, anything else | Ignored. A cancelled subscription is not renewed again; its license expires at its stored expiry. These events do not revoke immediately. |
## Good to know
- **Existing perpetual mappings.** Subscription purchases still issue on a perpetual plan. Events that identify a subscription show a warning; cancellation does not revoke lifetime access. Existing perpetual licenses remain perpetual on renewal. Choose a finite plan for access that should expire, and review [[guides/sales-platform-operations|subscription access and recovery]] before changing existing mappings.
- **Finite subscription plans.** Use a plan duration matching the billing interval. Initial purchases and free trials grant that duration; trial-end synchronization is not provided. Renewals extend to the invoice creation time plus the mapped plan duration, rather than importing the provider’s next billing date. Cancellation or a failed renewal leaves the stored expiry unchanged. Verify these dates in your rehearsal.
- **First item only.** The order webhook names the first item; LicenseSeat uses its quantity when present and otherwise defaults to one. A subscription-first purchase defaults to one. Use separate orders with quantity one for predictable subscription fulfillment; later cart items are not fulfilled.
- **Renewals need history.** A renewal only extends a license whose first purchase LicenseSeat already saw. Subscriptions that started before you connected cannot be matched automatically. A manual license with the same email still lacks subscription provenance; replay the initial purchase/subscription events or maintain access manually.
- **Signatures.** Lemon Squeezy signs every request with your secret (HMAC-SHA256 of the raw body, sent in the `X-Signature` header). LicenseSeat rejects anything else with `401`.
- **Retries.** If LicenseSeat does not answer `200`, Lemon Squeezy retries up to three more times with growing delays (about 5, 25 and 125 seconds), then gives up; you can still **Resend** from Recent deliveries. LicenseSeat answers immediately and processes the event in the background, and duplicate deliveries of the same event are recognised, so an order is never fulfilled twice.
- **Test orders.** Orders placed in Test Mode carry `test_mode: true` and are treated like any other order once the test webhook exists. Revoke those licenses afterwards.
- **Custom checkout data.** You do not need to pass anything through `checkout[custom][...]`: the order already names the variant, and that is what LicenseSeat matches on.
- **Changing providers.** A new Stripe connection does not migrate Lemon Squeezy subscription provenance. Plan a separate migration and test renewals/refunds before switching.
## Troubleshooting
- **The connection stays "Incomplete".** No product is linked yet. Click **Link Product** on the connection page.
- **Real orders never arrive, test orders do.** The webhook was created while Test Mode was on. Turn Test Mode off and create another webhook with the same URL, secret and events.
- **The event shows as "ignored" with "no active mapping".** The order's variant ID does not match any linked product. Use **Copy ID** on the exact variant sold, and check you linked the live ID rather than a Test Mode one.
- **Recent deliveries shows `401`.** The signing secret in Lemon Squeezy does not match. Open the webhook there, paste the secret from the LicenseSeat connection page again, and **Resend** the delivery.
- **A refund did not revoke.** The refund must be issued on the order (or subscription invoice) that issued the license. Refunds of orders placed before the connection existed cannot be matched.
- **A renewal did not extend.** Either `subscription_payment_success` is not ticked on the webhook, or the subscription started before the connection existed.
## References
These are Lemon Squeezy's own pages. If a label or menu in the Lemon Squeezy dashboard ever differs from this guide, they are the source of truth:
- [Creating webhooks](https://docs.lemonsqueezy.com/help/webhooks) — Settings » Webhooks, the three elements (Callback URL, Signing secret, List of events), the 6-40 character secret rule, and the delivery log with resend.
- [Sync with webhooks (developer guide)](https://docs.lemonsqueezy.com/guides/developer-guide/webhooks) — the dashboard steps ("click the plus icon"), the `meta` / `data` envelope, `meta.custom_data`, retries, and the "Simulate event" option on test-mode subscriptions.
- [Webhook requests](https://docs.lemonsqueezy.com/help/webhooks/webhook-requests) — the `Content-Type`, `X-Event-Name` and `X-Signature` headers, the retry schedule, and a full `order_created` payload.
- [Signing requests](https://docs.lemonsqueezy.com/help/webhooks/signing-requests) — `X-Signature` as an HMAC-SHA256 hex digest of the raw body, with code samples.
- [Event types](https://docs.lemonsqueezy.com/help/webhooks/event-types) — every event name with its description, which object each carries, and the recommended minimum set.
- [Example payloads](https://docs.lemonsqueezy.com/help/webhooks/example-payloads) — `order_created`, `subscription_created`, `subscription_cancelled`, `subscription_paused`.
- [Simulate webhook events](https://docs.lemonsqueezy.com/help/webhooks/simulate-webhook-events) — which events can be simulated, and the rule that test and live webhooks are kept separate.
- [Test mode](https://docs.lemonsqueezy.com/help/getting-started/test-mode) — the Test Mode toggle, test card numbers, "Copy to Live Mode".
- [Taking payments (developer guide)](https://docs.lemonsqueezy.com/guides/developer-guide/taking-payments) — "Variant IDs can be copied from product dropdown menus, using the 'Copy ID' option".
- [Variants](https://docs.lemonsqueezy.com/help/products/variants) and [the Variant object](https://docs.lemonsqueezy.com/api/variants/the-variant-object) — the single "Default" variant rule.
- [The Order object](https://docs.lemonsqueezy.com/api/orders/the-order-object) — `first_order_item.variant_id`, `user_email`, `total`, `status`, `refunded`.
- [The Subscription invoice object](https://docs.lemonsqueezy.com/api/subscription-invoices/the-subscription-invoice-object) — `billing_reason` (`initial`, `renewal`, `updated`), `subscription_id`.
- [Passing custom data](https://docs.lemonsqueezy.com/help/checkout/passing-custom-data) — `checkout[custom][...]` and where it lands (`meta.custom_data`).
- [API changelog](https://docs.lemonsqueezy.com/api/getting-started/changelog) — no webhook deprecations as of July 2026.
- [Stripe acquires Lemon Squeezy](https://www.lemonsqueezy.com/blog/stripe-acquires-lemon-squeezy) (July 2024) and [2026 update: Stripe Managed Payments](https://www.lemonsqueezy.com/blog/2026-update) (January 2026).
---
## Selling software licenses with Paddle
# Selling software licenses with Paddle
Connect Paddle Billing to LicenseSeat and every completed payment issues a license key and emails it to the buyer. A subscription renewal extends the license; a refund or chargeback revokes it. Nothing to install on Paddle's side: the connection is a notification destination you create in your Paddle dashboard.
This guide is for Paddle Billing, Paddle's current platform. It does not apply to Paddle Classic.
Setup takes about five minutes.
## Before you start
Link the product to a license plan before testing. Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. Issuance can succeed without sending an email. Use a separate test organization when you need complete separation: connections in one organization share product mappings by provider.
Read [[guides/sales-platform-operations|Email delivery, subscription expiry and event recovery]] for the common prerequisites and recovery procedure. Setup instructions remain available on the connection page after saving.
## 1. Create the connection in LicenseSeat
In the LicenseSeat dashboard sidebar open **Connected platforms**, click **Connect Your First Platform** (or **Connect Another**), choose **Paddle**, and give the connection a name. The page shows your **webhook URL**. Copy it. Keep it private: only paste it into Paddle.
## 2. Create a notification destination in Paddle
In Paddle go to **Paddle → Developer tools → Notifications** and click **New destination**. Fill the dialog like this:
| Field | Value |
|---|---|
| Description | any name, for example "LicenseSeat" |
| Notification type | webhook |
| URL or email | your LicenseSeat webhook URL from step 1 |
| API version | leave Paddle's default |
| Usage type | **Platform and simulation** if you want to test with Paddle's simulator (step 6); otherwise **Platform** |
Under the events list tick these five, then click **Save destination**:
- `transaction.completed` — a payment finished processing. This is what issues (or renews) the license.
- `adjustment.created` and `adjustment.updated` — refunds and chargebacks. These revoke the license.
- `customer.created` and `customer.updated` — Paddle's transaction webhooks carry a customer ID but no email address, so LicenseSeat learns the buyer's email from these.
Do not use `transaction.paid`: Paddle fires it before it has finished processing the payment, and at that point a subscription may not yet have its subscription ID. `transaction.completed` is the one Paddle documents as "fully processed".
Paddle lets you create as many destinations as you like, but only 10 can be active at once.
## 3. Paste the signing secret
Back on the Notifications list, click the menu button next to the destination you just created and choose **Edit destination**. Click the copy icon in the **secret key** field; the key starts with `pdl_ntfset_`. Paste it into the **Signing Secret** field on the LicenseSeat connection page and save.
Paddle signs every webhook with this key and LicenseSeat rejects anything that is not signed with it. Each destination has its own key, so if you create a second destination later, use that one's key.
**Sandbox and live are separate accounts.** Paddle's sandbox dashboard is at `sandbox-vendors.paddle.com`, live is at `vendors.paddle.com`, and nothing is shared between them: not notification destinations, not secret keys, not products or price IDs. When you go live, create the destination again in your live account and paste its secret key into LicenseSeat (a second LicenseSeat connection for live keeps the two apart cleanly).
## 4. Link your prices
Tell LicenseSeat which license plan to issue for each Paddle price. On the connection page click **Link Product**, pick the plan, and enter the Paddle identifier:
- **Price ID** (starts with `pri_`): in Paddle go to **Catalog → Products**, click the product, and under **Prices** copy the price's ID. Use this when a product has several prices (monthly and yearly, personal and studio) that should issue different licenses.
- **Product ID** (starts with `pro_`): also accepted. Use it when the product has a single price. If both a price and its product are linked, the price wins.
Sandbox and live price IDs are different; link the ones from the account your destination lives in.
## 5. Optional: pass the buyer's email at checkout
If you open Paddle Checkout from your own site, include the buyer's email as custom data. LicenseSeat then has the email on the transaction itself and never has to wait for the customer webhook:
```js
Paddle.Checkout.open({
items: [{ priceId: "pri_...", quantity: 1 }],
customer: { email: buyerEmail },
customData: { email: buyerEmail }
});
```
On an HTML checkout button the equivalent is `data-custom-data='{"email":"..."}'`. LicenseSeat reads the keys `email`, `customer_email` or `buyer_email`. Paddle copies a subscription's custom data onto every transaction it creates for it (renewals, upgrades, one-time charges), so one checkout covers the whole life of the subscription.
If you use Paddle's hosted checkout links or don't control the checkout, skip this step: the `customer.created` / `customer.updated` events from step 2 supply the email.
## 6. Test it
Paddle can send a signed test webhook without a real payment. In Paddle go to **Developer tools → Simulations** and click **New simulation**. Choose your destination in the **Destination** dropdown, give the simulation a name, open the **Single event** tab, pick `transaction.completed`, and click **Create**. Click **Run simulation**.
Paddle's sample payload uses a demo price and a demo customer, so edit it: open the **Payload** tab in the preview pane, click the payload to edit it, set `items[0].price.id` to a price you linked in step 4, add `"custom_data": {"email": "you@example.com"}`, and click **Replay**. Within a few seconds the LicenseSeat connection shows **Active** and a license appears under your product, emailed to that address. If the sample's `origin` is not `web`, change it to `web` as well; LicenseSeat only fulfils checkout, API and one-time-charge origins.
This needs the destination's **Usage type** to be **Platform and simulation**; a **Platform**-only destination never receives simulated events. Simulations work in both sandbox and live accounts, and their webhooks are signed with the destination's real secret key, so a signature failure here means the secret in LicenseSeat is wrong.
For a full rehearsal, use a sandbox account: buy the product through your own checkout with card `4242 4242 4242 4242` (any name, any future expiry). Then in Paddle go to **Transactions**, open the transaction, click **Refund**, enter the amount, click **Continue**, then **Request refund**. Sandbox approves refunds automatically within about ten minutes. On the connection's event list you should see the license issued, emailed, and then revoked when the approval arrives.
## What happens on each event
| Paddle event | LicenseSeat |
|---|---|
| `transaction.completed` with origin `web`, `api` or `subscription_charge` | Issues a license for the first item that matches a linked price (or its product), owned by the buyer's email, and emails the key. Quantity follows the item. |
| `transaction.completed` with origin `subscription_recurring` | Renews the license issued for that subscription to the end of the new billing period. |
| `transaction.completed` with origin `subscription_update` or `subscription_payment_method_change` | Ignored. Plan changes, prorations and card updates do not issue or extend anything. |
| `adjustment.created` / `adjustment.updated` with action `refund`, status `approved` | Revokes all licenses correlated with the transaction or subscription. Any approved partial refund revokes too, including adjustments for another item in the transaction. |
| `adjustment.created` with action `chargeback` | Revokes the license issued for that transaction. |
| `adjustment.*` with status `pending_approval` or `rejected` | Nothing. Most live refunds start as `pending_approval` and are reviewed by Paddle; the revoke happens when `adjustment.updated` arrives with `approved`. |
| `customer.created` / `customer.updated` | Stored so the buyer's email can be matched to their transactions. Shown as ignored on the event list, which is expected. |
| `subscription.canceled` | Ignored. The license simply is not renewed; it expires at its stored expiry. Initial access uses the mapped plan duration, which must match the billing interval; later renewals use the provider’s billing period end. |
| Anything else | Ignored. Subscribing to extra events does no harm. |
## Good to know
- **Existing perpetual mappings.** Subscription purchases still issue on a perpetual plan. Events that identify a subscription show a warning; cancellation does not revoke lifetime access. Existing perpetual licenses remain perpetual on renewal. Choose a finite plan for access that should expire, and review [[guides/sales-platform-operations|subscription access and recovery]] before changing existing mappings.
- **Finite subscription plans.** Choose a license plan with a duration matching the billing interval. Initial issuance uses this duration; it does not synchronize a trial or initial provider period. Subsequent renewals use the transaction billing period end. Verify both stages before selling subscriptions.
- **Buyer email.** Paddle's transaction webhooks carry a `customer_id` and no email. LicenseSeat looks in the transaction's custom data first (step 5), then in the `customer.created` / `customer.updated` events it has stored. If neither has arrived yet, the event waits and retries with finite automatic attempts; retained missing-history failures are eligible for recovery when newer provider history arrives; Paddle does not guarantee webhook order, so the customer event can land after the transaction. Replay the relevant customer notification in Paddle; a no-op save is not a guaranteed new event.
- **Existing customers.** `customer.created` only fires for customers created after you connected. Renewals for subscriptions that predate the connection have no stored customer event and no license to renew; they show as failed or ignored. Issuing a license manually with the same email does **not** attach the Paddle subscription ID. Future renewals still cannot match it. Replay the original purchase with its subscription/customer history, or manage that license manually; automatic import of pre-existing subscriptions is not provided.
- **Live refunds need Paddle's approval.** Paddle approves automatically only for verified, active accounts when the refund is 400 USD or less and below your balance; otherwise a person at Paddle reviews it. The license stays valid until the approval webhook arrives.
- **Retries.** Paddle expects a 200 within five seconds and retries with exponential backoff: 3 times within 15 minutes in sandbox, 60 times over 3 days on live. LicenseSeat answers immediately and processes in the background, and duplicate deliveries are recognised by Paddle's `event_id`, so a payment is never fulfilled twice.
- **First matching item, matching quantity.** Try each item in order, its Price ID before its Product ID, and issue that item’s quantity of licenses (1–1000). Later items are not fulfilled. Use separate checkouts for separately licensed products.
- **Signature timing.** Paddle's own SDKs reject webhooks whose timestamp is more than five seconds old. LicenseSeat allows five minutes, so a slow hop or a little clock drift on Paddle's side does not turn into a rejected payment.
## Troubleshooting
- **The connection stays "Incomplete".** The signing secret is missing, or no price is linked yet. Both are on the connection page.
- **Paddle shows failed deliveries (HTTP 401).** The signing secret does not match. Open the destination (menu → **Edit destination**) and copy the secret key again. Remember sandbox and live destinations have different keys.
- **The event shows as "ignored" with "no active mapping".** The transaction's price ID does not match any linked product. Compare the `pri_` in the event payload with what you linked; sandbox IDs never match live ones. Simulated events use Paddle's demo price unless you edit the payload (step 6).
- **The event keeps retrying, or fails with "awaiting customer.created/updated".** LicenseSeat has no email for that customer. Check that `customer.created` and `customer.updated` are ticked on the destination, or pass the email as custom data at checkout (step 5). For a simulated event, add `custom_data.email` to the payload.
- **A renewal shows as "ignored".** Its origin was `subscription_update` or `subscription_payment_method_change` (a plan change or card update), which is by design. Only `subscription_recurring` renews.
- **A refund did not revoke.** The refund is probably still `pending_approval`; the revoke arrives with `adjustment.updated`. Make sure that event is ticked on the destination. Refunds of transactions made before the connection existed cannot be matched.
- **Paddle will not let you save a new destination.** Only 10 destinations can be active at once; deactivate an old one.
- **Simulations arrive but real purchases don't (or the reverse).** Check the destination's **Usage type**: **Platform** receives only real events, **Simulation** only simulated ones, **Platform and simulation** both.
## References
These are Paddle's own pages. If a label or menu in the Paddle dashboard ever differs from this guide, they are the source of truth:
- [Create or update notification destinations](https://developer.paddle.com/webhooks/about/notification-destinations) — Developer tools → Notifications, the New destination dialog fields (Description, Notification type, URL or email, API version, Usage type), Save destination, Edit destination, the 10-active-destinations limit.
- [Verify webhook signatures](https://developer.paddle.com/webhooks/about/signature-verification) — where the `pdl_ntfset_` secret key is (Edit destination → secret key field), the `Paddle-Signature` header (`ts` / `h1`), HMAC-SHA256 over `ts:body`, the five-second SDK tolerance.
- [Handle webhook delivery](https://developer.paddle.com/webhooks/about/respond-to-webhooks) — respond with 200 within five seconds; retry schedule (sandbox 3 in 15 minutes, live 60 over 3 days); replaying failed notifications.
- [How webhooks work](https://developer.paddle.com/webhooks/about/how-webhooks-work) — at-least-once delivery, `event_id` deduplication, `occurred_at` ordering, when to use `transaction.completed`.
- [Simulate webhooks](https://developer.paddle.com/webhooks/test-webhooks) — Developer tools → Simulations, New simulation, Single event / Scenario, Run simulation, Replay, editing the Payload tab, the Usage type requirement, signed simulated events.
- [Webhook simulator overview](https://developer.paddle.com/webhooks/simulator) — works in sandbox and production.
- [`transaction.completed`](https://developer.paddle.com/webhooks/transactions/transaction-completed) — when it fires, the `origin` values, `customer_id`, `custom_data`, `items[].price.id`, `items[].price.product_id`.
- [`transaction.paid`](https://developer.paddle.com/webhooks/transactions/transaction-paid/) — why it is not used for fulfilment.
- [`adjustment.created`](https://developer.paddle.com/webhooks/adjustments/adjustment-created) and [`adjustment.updated`](https://developer.paddle.com/webhooks/adjustments/adjustment-updated) — refund and chargeback actions, `pending_approval` → `approved` / `rejected`.
- [Refund or credit a transaction](https://developer.paddle.com/build/transactions/create-transaction-adjustments) — Transactions → Refund → Continue → Request refund; automatic approval rules; sandbox auto-approval every ten minutes.
- [`customer.created`](https://developer.paddle.com/webhooks/customers/customer-created) and [`customer.updated`](https://developer.paddle.com/webhooks/customers/customer-updated) — the events that carry the buyer's email.
- [`subscription.canceled`](https://developer.paddle.com/webhooks/subscriptions/subscription-canceled) — immediate vs. end-of-period cancellation.
- [Subscription renewed scenario](https://developer.paddle.com/webhooks/scenarios/subscription-renewed) — the seven events of a renewal; `origin` is `subscription_recurring`.
- [Work with custom data](https://developer.paddle.com/build/transactions/custom-data) — `customData` in `Paddle.Checkout.open()`, the `data-custom-data` attribute, custom data copied between subscriptions and their transactions.
- [`Paddle.Checkout.open()`](https://developer.paddle.com/paddlejs/methods/paddle-checkout-open) — `items[].priceId`, `customer.email`, `customData`.
- [Create products and prices](https://developer.paddle.com/build/products/create-products-prices) — Catalog → Products → New product; Prices → New price; `pro_` and `pri_` IDs.
- [Sandbox](https://developer.paddle.com/build/tools/sandbox) — separate sandbox and live accounts (`sandbox-vendors.paddle.com` vs `vendors.paddle.com`), nothing shared between them, test card numbers, go-live checklist.
- [Provision access with webhooks](https://developer.paddle.com/build/subscriptions/provision-access-webhooks) — Paddle's general guidance for SaaS access control (`subscription.created` / `subscription.updated`); LicenseSeat issues per payment, so it uses `transaction.completed` instead.
---
## Selling software licenses on Polar.sh
# Selling software licenses on Polar.sh
Connect a [Polar](https://polar.sh) organization to LicenseSeat and paid orders for linked products issue license keys; eligible email delivery sends them to the buyer. A subscription renewal extends existing access, and a successful refund revokes it. No code on your side: the connection uses a webhook endpoint you add in your Polar dashboard.
Setup takes about five minutes.
## Before you start
Link the product to a license plan before testing. Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. Issuance can succeed without sending an email. Use a separate test organization when you need complete separation: connections in one organization share product mappings by provider.
Read [[guides/sales-platform-operations|Email delivery, subscription expiry and event recovery]] for the common prerequisites and recovery procedure. Setup instructions remain available on the connection page after saving.
## 1. Create the connection in LicenseSeat
In the LicenseSeat dashboard sidebar open **Connected platforms**, click **Connect Your First Platform** (or **Connect Another**), choose **Polar**, and give the connection a name. The page shows your **webhook URL**. Copy it. Keep it private: only paste it into Polar.
## 2. Add an endpoint in Polar
In your Polar dashboard go to **Settings → Webhooks** and click **Add Endpoint**. Fill the form like this and save:
| Field | Value |
|---|---|
| URL | your LicenseSeat webhook URL from step 1 |
| Format | **Raw** |
| Events | tick `order.paid` and `order.refunded` |
*order.paid* fires when an order is collected, and that is what issues the license. On a subscription it also fires on every renewal, which extends the license. *order.refunded* fires when you refund the order, fully or partially, and that revokes the license.
Subscribe to both events above. `refund.created` and `refund.updated` are ignored: a created refund can still be pending or fail. Only `order.refunded` confirms a successful refund. See [Polar’s refund guide](https://polar.sh/docs/features/refunds).
## 3. Paste the endpoint's secret
Polar shows a **Secret** for the endpoint. It starts with `whsec_`. Copy it into the **Signing Secret** field on the LicenseSeat connection page and save.
Polar signs every webhook with this secret, and LicenseSeat rejects anything that is not signed with it. Polar uses a new signing scheme for secrets generated on or after 8 September 2026 UTC ([Standard Webhooks](https://www.standardwebhooks.com/)); secrets created before and after that date both work with LicenseSeat, so there is nothing to migrate. If you regenerate the secret in Polar (**Reset webhook endpoint secret**), paste the new one here.
## 4. Link your products
Tell LicenseSeat which license plan to issue for each Polar product. On the connection page click **Link Product**, pick the plan, and enter the **Product ID**: in your Polar dashboard open **Products**, click the **⋯** menu in front of the product and choose **Copy Product ID**. It is a UUID such as `1dbfc517-0bbf-4301-9ba8-555ca42b9737`. Pasting the product page URL also works; LicenseSeat keeps the ID.
A Polar checkout sells one product per order, so there is nothing to choose between lines. If an order contains several prices for a product, one mapping on the Product ID covers them. To issue a different plan per price, link the Price ID instead; LicenseSeat checks the Product ID first, then each price on the order.
Selling a subscription? Initial access uses the mapped license plan’s duration, including trial signups; it does **not** automatically match the trial end or first billing-period end. Later paid renewals extend finite licenses using Polar’s current period end, without shortening access already granted. Pick a finite plan deliberately and test the actual expiry. A perpetual plan still issues perpetual access with a **Subscription plan warning**; cancellation does not end that access.
**Seats:** initial fulfillment issues one license per seat (up to 1,000), including subscription trials, all owned by the buyer. Later seat increases, decreases, plan changes and member assignments are **not synchronized**. Renewals extend the existing licenses without changing their count. Manage additional licenses and revoke excess licenses manually in LicenseSeat. Licenses created manually do not automatically follow the subscription’s renewals or refunds; manage their expiry and revocation too. See [Polar’s seat model](https://polar.sh/docs/features/seat-based-pricing).
## 5. Test it
Polar's [sandbox](https://sandbox.polar.sh/start) is a completely separate environment with its own account, organization, products, webhooks and secret. No money moves in Polar’s sandbox. Its webhooks still create real licenses and can send emails in the connected LicenseSeat organization.
For a rehearsal:
1. Create a second connection in LicenseSeat for the sandbox.
2. In the sandbox dashboard, add the endpoint the same way as in steps 2 and 3, with the sandbox's own secret.
3. Link the sandbox product's ID to a plan.
4. Buy the product through its checkout link with card `4242 4242 4242 4242`, any future expiry and any CVC.
After processing, verify the connection is **Active** and check the licenses under your product: quantity, owner, expiry and email delivery separately. For subscriptions, also test a renewal; for trials, verify the initial plan duration is acceptable. Then open the order in the sandbox dashboard, scroll to **Refunds** and click **Refund order**. The license is revoked. Revoke or delete the test licenses when you are done.
Every delivery, live or sandbox, is listed on the endpoint's page under Settings → Webhooks, with its payload, its response, and a **Redeliver** button.
## What happens on each event
| Polar event | LicenseSeat |
|---|---|
| `order.paid`, billing reason *purchase* | Issues a license for the linked product, owned by the customer when an email is present. Eligible email delivery sends the key. Initial seat-based orders issue one license per seat. |
| `order.paid`, billing reason *subscription_create* | Issues licenses for the initial seat count and records the subscription. Initial expiry uses the mapped plan duration, including trials. |
| `order.paid`, billing reason *subscription_cycle* | Extends existing finite licenses using the new period end without shortening existing access; perpetual licenses stay perpetual. Does not change seat quantity. |
| `order.paid`, billing reason *subscription_update* or *subscription_meter_cycle* | Ignored. A prorated plan change or a usage invoice is not new access. |
| `order.created`, `order.updated` | Ignored unless the order is already paid, in which case they behave like `order.paid`. An order never issues twice. |
| `order.refunded` | Revokes all licenses issued for that order. Any refund does this, including a partial one. |
| `refund.created`, `refund.updated` | Ignored, including pending and failed refunds. Subscribe to `order.refunded` for successful refunds. |
| `subscription.canceled`, `subscription.revoked`, `subscription.paused`, `subscription.past_due` | Ignored. Finite access ends at the license's stored expiry when no renewal arrives; perpetual access remains. |
| Anything else | Ignored. Extra events do not replace the two required events. |
## Good to know
- **No buyer email.** Polar customers almost always have an email, but if an order arrives without one the license is still issued, without an owner. An organization owner/admin can use **Assign customer** on the license page and optionally send the key if email delivery is enabled.
- **Cancellations do not revoke.** Like every other platform LicenseSeat connects to, a canceled subscription keeps its license until its stored expiry. Initial expiry uses the plan duration, not the provider’s first period or trial end. Perpetual licenses have no expiry. A successful refund revokes immediately.
- **Retries and disabling.** Polar retries a failed delivery up to ten times with exponential backoff, and disables the endpoint after ten consecutive failures, emailing everyone in your organization. LicenseSeat acknowledges receipt before background processing; check processing status separately. If Polar disables delivery, fix the cause (for example a changed secret or unavailable endpoint) and re-enable the endpoint from Settings → Webhooks.
- **Replay protection.** A delivery whose timestamp is more than five minutes old is rejected, as the Standard Webhooks specification recommends. Polar re-signs redeliveries, so **Redeliver** can pass the timestamp check with the current correct secret.
- **Duplicates.** Polar's `webhook-id` identifies each message across retries, so a delivery received twice is processed once.
- **Polar's own license keys.** If the product also has a Polar *License keys* benefit, both keys are issued. Remove the benefit from the product once LicenseSeat is connected so buyers receive a single key.
## Troubleshooting
- **The connection stays "Incomplete".** The signing secret is missing, or no product is linked yet. Both are on the connection page.
- **The event shows as "ignored" with "no active mapping".** The order's Product ID does not match any linked product. Copy the ID again with **Copy Product ID**, and check that you linked the production product, not the sandbox one.
- **Polar shows a failed delivery with status 401.** The signing secret does not match. Copy it again from the endpoint's page in Polar. If the endpoint was disabled after repeated failures, re-enable it after fixing the secret.
- **Polar shows a failed delivery and the timestamp is old.** The delivery was replayed more than five minutes after it was signed. Use **Redeliver** in Polar, which signs a fresh copy.
- **A refund did not revoke.** Check that the refund succeeded and that `order.refunded` is selected. The order must correlate with an issued license or its subscription; refunding a renewal can revoke the whole subscription, including access paid for by later orders. If no license matches, the refund is ignored and retained, without repeated failed jobs. A later matching purchase or recovered history can resolve it automatically; manual retry remains available. Recover the missing purchase or revoke manually; see the recovery guide.
## References
These are Polar's own pages. If a label or menu in the Polar dashboard ever differs from this guide, they are the source of truth:
- [Setup webhooks](https://docs.polar.sh/integrate/webhooks/endpoints) — adding an endpoint under Settings → Webhooks, the Raw format, the secret, and choosing events.
- [Webhook events](https://docs.polar.sh/integrate/webhooks/events) — every event, including `order.paid` and `order.refunded`, and the sequences fired on renewals and cancellations.
- [Handle and monitor deliveries](https://docs.polar.sh/integrate/webhooks/delivery) — signature validation for both secret generations, retries, the ten-second timeout, and endpoint disabling.
- [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) — the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers.
- [Orders](https://docs.polar.sh/features/orders) — order statuses and the `billing_reason` values.
- [Refunds](https://docs.polar.sh/features/refunds) — issuing full and partial refunds from an order's page.
- [Sandbox environment](https://docs.polar.sh/integrate/sandbox) — the separate test environment and the `4242 4242 4242 4242` card.
- [Order webhook payload](https://polar.sh/docs/api-reference/current/order_paid) — the fields LicenseSeat reads (`product_id`, `customer.email`, `total_amount`, `subscription.current_period_end`, `seats`).
- [Seat-based pricing](https://polar.sh/docs/features/seat-based-pricing) — Polar seats and member assignments; the LicenseSeat connector only automates the initial quantity.
- [Versioned webhook schema](https://polar.sh/docs/openapi/2026-04.openapi.json) — the reviewed Order and embedded OrderSubscription contract.
---
## Selling software licenses on Shopify
# Selling software licenses on Shopify
Connect a Shopify store to LicenseSeat and paid orders for linked products issue license keys; eligible email delivery sends them to the buyer. A refund revokes it. No Shopify app to install: the connection uses the webhooks you create yourself in your Shopify admin.
Setup takes about five minutes.
## Before you start
Link the product to a license plan before testing. Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. Issuance can succeed without sending an email. Use a separate test organization when you need complete separation: connections in one organization share product mappings by provider.
Read [[guides/sales-platform-operations|Email delivery, subscription expiry and event recovery]] for the common prerequisites and recovery procedure. Setup instructions remain available on the connection page after saving.
## 1. Create the connection in LicenseSeat
In the LicenseSeat dashboard sidebar open **Connected platforms**, click **Connect Your First Platform** (or **Connect Another**), choose **Shopify**, and give the connection a name. The page shows your **webhook URL**. Copy it. Keep it private: only paste it into Shopify.
## 2. Create two webhooks in Shopify
From your Shopify admin, go to **Settings → Notifications**, click **Webhooks**, then **⊕ Create webhook**. Fill the dialog like this and save:
| Field | Value |
|---|---|
| Event | **Order payment** |
| Format | **JSON** |
| URL | your LicenseSeat webhook URL from step 1 |
| Webhook API version | Choose a stable version offered by Shopify and test it; repeat your rehearsal before changing versions. |
Then click **⊕ Create webhook** again and repeat with the event **Refund create**.
*Order payment* fires when an order's payment goes through, and that is what issues the license. *Refund create* fires when you refund the order in Shopify admin, and that revokes the license. You do not need *Order creation*: an unpaid order never issues anything, and a paid one is already covered.
## 3. Paste the signing secret
Under the list of webhooks on that same Shopify page there is a line that reads *"Your webhooks will be signed with"* followed by a long code. Copy that code into the **Signing Secret** field on the LicenseSeat connection page and save.
Shopify signs every webhook with this secret, and LicenseSeat rejects anything that is not signed with it. If you created the webhooks through a custom Shopify app instead of the Notifications page, use that app's client secret.
## 4. Link your products
Tell LicenseSeat which license plan to issue for each Shopify product. On the connection page click **Link Product**, pick the plan, and enter the Shopify identifier:
- **Product ID**: in Shopify admin open **Products**, click the product, and copy the number at the end of the page URL. Use this when every variant of the product should issue the same license.
- **Variant ID**: open the specific variant and copy the number at the end of its URL. Use this when different variants issue different licenses (for example a personal and a studio edition of the same plugin).
- **SKU**: a variant's SKU also works.
Pasting the whole Shopify admin URL works; LicenseSeat keeps the number.
## 5. Test it
On Shopify's Webhooks page, open the **⋯** menu next to your **Order payment** webhook and click **Send test**. A verified sample appears in the connection's events. It issues only if the sample contains a product/variant/SKU you linked; otherwise it is ignored. Samples can have no buyer email and then issue ownerless licenses. Check the payload and license, rather than expecting every sample to activate the connection. Real orders can also lack email; delivery needs the prerequisites above.
For a full rehearsal, place an order in your own store. On a store without a live payment provider yet, add a manual payment method (Settings → Payments → Manual payment methods), buy the product, then open the order in Shopify admin and click **Mark as paid**. Then click **Refund** on the same order. You should see the license issued, emailed, and then revoked, in that order, on the connection's event list.
## What happens on each event
| Shopify event | LicenseSeat |
|---|---|
| Order payment (`orders/paid`) | Issues a license for the first line item that matches a linked product, owned by the buyer's email, and emails the key. Quantity follows the line item. |
| Order creation (`orders/create`) | Ignored unless the order is already paid, in which case it behaves like Order payment. An order never issues twice. |
| Refund create (`refunds/create`) | Revokes all licenses issued for that order. Any refund does this, including a partial refund, a shipping-only refund, or a refund for a different line in the same order. |
| Order cancellation | Ignored. A cancellation that returns money also sends Refund create, which is what revokes. |
| Anything else | Ignored. Subscribing to extra events does no harm. |
## Good to know
- **First matching line, matching quantity.** Check each line in order, trying its Variant ID, Product ID, then SKU. Issue that line’s quantity of separate licenses (1–1000). Later matching lines are not fulfilled. Use separate orders for separately licensed products; mapping a variant does not turn this into fulfillment of every cart line.
- **No buyer email.** Orders with no email address at all (some POS and phone-only checkouts, and Shopify's sample order) still issue a license, without an owner. An organization owner/admin can use **Assign customer** on the license page and optionally send the key if email delivery is enabled.
- **Subscriptions.** Shopify selling plans and subscription apps are not modelled. Every paid order is treated as a one-time purchase.
- **Retries.** Shopify retries a webhook up to eight times over four hours if it does not get a 200. LicenseSeat answers immediately and processes the event in the background, and duplicate deliveries are recognised by Shopify's webhook id, so an order is never fulfilled twice.
- **Test orders.** Orders placed with a test payment gateway carry `test: true` and are treated like any other paid order. Revoke the test licenses afterwards, or refund the test order.
## Troubleshooting
- **The connection stays "Incomplete".** The signing secret is missing, or no product is linked yet. Both are on the connection page.
- **The event shows as "ignored" with "no active mapping".** The order's product or variant id does not match any linked product. Check the number you entered against the end of the product URL in Shopify admin, or link the variant instead of the product.
- **Shopify shows a failed delivery.** The signing secret does not match. Copy it again from the bottom of the Webhooks page; it is the store's secret, not your Shopify password.
- **A refund did not revoke.** The refund must be issued on the same order that issued the license. If no license matches, the refund is ignored and retained, without repeated failed jobs. A later matching purchase or recovered history can resolve it automatically; manual retry remains available. Recover the missing purchase or revoke manually; see the recovery guide.
## References
These are Shopify's own pages. If a label or menu in Shopify admin ever differs from this guide, they are the source of truth:
- [Webhooks in Shopify admin](https://help.shopify.com/en/manual/fulfillment/setup/notifications/webhooks) — creating, editing, testing ("Send test") and deleting webhooks from Settings → Notifications → Webhooks.
- [Webhook topics reference](https://shopify.dev/docs/api/webhooks) — every topic, including `orders/paid`, `orders/create` and `refunds/create`, with payload descriptions.
- [Verifying webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe/https) — the `X-Shopify-Hmac-SHA256` signature, the `X-Shopify-Webhook-Id` duplicate key, retries and timeouts.
- [Order resource](https://shopify.dev/docs/api/admin-rest/latest/resources/order) — the fields in the order payload (`line_items`, `financial_status`, `email`, `total_price`).
- [Refund resource](https://shopify.dev/docs/api/admin-rest/latest/resources/refund) — the refund payload (`order_id`, `refund_line_items`).
- [Manual payment methods](https://help.shopify.com/en/manual/payments/manual-payments) — for placing a rehearsal order without a live payment provider.
- [ISO 4217 currency data](https://www.six-group.com/en/products-services/financial-information/data-standards.html) — Shopify decimal amounts are converted using currency-specific minor units, including JPY and BHD.
---
## Selling software licenses with Stripe Payment Links
# Selling software licenses with Stripe Payment Links
Connect your Stripe account to LicenseSeat and completed, paid checkouts through a linked Stripe Payment Link issue a license key; eligible email delivery sends it to the buyer. A refund revokes it. If the link sells a subscription, every renewal payment extends the license. Nothing to install: the connection is a webhook endpoint you create in your Stripe Dashboard.
Setup takes about five minutes.
## Before you start
Link the product to a license plan before testing. Automatic license emails require an eligible LicenseSeat plan, **Settings → Organization → Email license keys to my customers** enabled, and a valid buyer email. Issuance can succeed without sending an email. Use a separate test organization when you need complete separation: connections in one organization share product mappings by provider.
Read [[guides/sales-platform-operations|Email delivery, subscription expiry and event recovery]] for the common prerequisites and recovery procedure. Setup instructions remain available on the connection page after saving.
## 1. Create the connection in LicenseSeat
In the LicenseSeat dashboard sidebar open **Connected platforms**, click **Connect Your First Platform** (or **Connect Another**), choose **Stripe**, and give the connection a name. The page shows your **webhook URL**. Copy it. Keep it private: only paste it into Stripe.
## 2. Create the webhook endpoint in Stripe
In Stripe, open **Workbench** (from the **Developers** menu, or press `~` anywhere in the Dashboard) and go to the **Webhooks** tab. The direct link is [dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks). Click the button to add a destination. Stripe's own pages label it **Add destination** or **Create an event destination**; it is the only primary button on that tab.
Stripe asks for the events first and the URL last. Fill the wizard like this:
| Step | Value |
|---|---|
| Events from | **Your account** |
| API version | Use a stable snapshot event API version. Basil (2025-03-31) and later require `invoice_payment.paid` for subscription refund/dispute correlation; re-test before changing versions. |
| Events | `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `invoice_payment.paid`, `charge.refunded`, `charge.dispute.created` |
| Destination type | **Webhook endpoint** |
| Endpoint URL | your LicenseSeat webhook URL from step 1 |
Click **Continue** between pages and **Create destination** at the end.
*checkout.session.completed* reports a completed checkout. LicenseSeat waits if its payment is still unpaid; `no_payment_required` sessions (for example free trials) can issue immediately. *invoice.paid* fires on every subscription renewal, and that is what extends the license; if you sell yearly or monthly plans and skip this event, licenses expire after the first period and nothing tells you. *charge.refunded* fires when you refund the payment, and that revokes the license. *checkout.session.async_payment_succeeded* covers payment methods that settle days later (bank debits, vouchers). *charge.dispute.created* revokes access for a disputed payment. *invoice_payment.paid* is retained as payment-to-invoice history: modern subscription refunds and disputes need it, even though it issues no license itself.
Stripe keeps a **sandbox** and your **live** account fully separate: each has its own endpoints, signing secrets and Payment Links. Do this in your live account for real sales.
## 3. Paste the signing secret
When Stripe finishes creating the destination it opens the endpoint's page. Under **Signing secret** click **Reveal secret**. The value starts with `whsec_`. Copy it into the **Signing Secret** field on the LicenseSeat connection page and save.
Stripe signs every event with this secret, and LicenseSeat rejects anything that is not signed with it. Every endpoint has its own secret, and a sandbox endpoint's secret is different from the live one. If you ever recreate the endpoint in Stripe, or roll its secret, paste the new value here.
## 4. Link your Payment Links
Tell LicenseSeat which license plan to issue for each Stripe **Payment Link**. On the connection page click **Link Payment Link**, pick the plan, and enter the link's ID:
- In Stripe open **Payment Links**, click the link you sell with, and copy its ID. It starts with `plink_` (for example `plink_1MoC3ULkdIwHu7ixZjtGpVl2`).
- It is **not** the `buy.stripe.com/…` address you share with customers. That address does not contain the ID and will not be accepted.
- One Payment Link maps to one license plan. If you sell a personal and a studio edition, make two Payment Links and link each to its plan.
Only purchases made through a Payment Link issue licenses. A Payment Link's **Buy button** is generated from the link and sends the same event; test one purchase to confirm it before relying on it. Checkout Sessions you create yourself with the Stripe API carry no Payment Link ID and are ignored.
## 5. Test it
Start in a Stripe sandbox. Switch to a sandbox in the Dashboard's account picker, create the endpoint with all six events (step 2) and a Payment Link there, then add a second LicenseSeat Stripe connection with that endpoint's signing secret. Link the sandbox Payment Link before buying. Pay with Stripe's test card `4242 4242 4242 4242`, any future expiry and any CVC. Verify the license, its owner and email delivery separately; then refund the payment and verify revocation. For subscriptions, also exercise a renewal, a refund of that renewal, and a dispute.
For real sales, repeat the setup in your live Stripe account with live IDs and secrets. A successful sandbox test does not verify live delivery. If you choose to make a live rehearsal purchase, check Stripe's current fees first; do not assume a refund returns processing fees.
Stripe's `stripe trigger checkout.session.completed` command does **not** work as a test: the sample session it creates is not tied to a Payment Link, so LicenseSeat ignores it.
## What happens on each event
| Stripe event | LicenseSeat |
|---|---|
| `checkout.session.completed` | When payment is `paid` or `no_payment_required`, issues one license for the plan linked to the session's Payment Link, owned by the email the buyer entered at checkout, and emails the key. Quantity is always one, whatever the buyer chose. |
| `checkout.session.async_payment_succeeded` | Same as above, for payment methods that confirm later. If the earlier `completed` event already issued the license, this one is recognised as a duplicate and ignored. |
| `invoice.paid` | Only when the invoice's reason is `subscription_cycle` (a renewal): extends the license to the end of the new billing period. The first invoice of a subscription is ignored because Checkout already issued the license. |
| `invoice_payment.paid` | Retains the payment-to-invoice relationship for modern subscription refunds/disputes; appears as ignored because it does not fulfill a purchase. |
| `charge.refunded` | Revokes the license issued for that payment and, when the refund includes an email, queues a refund notice. Partial refunds revoke too. |
| `charge.dispute.created` | Revokes the license bought with the disputed payment. |
| Anything else | Ignored. Subscribing to extra events does no harm. |
## Good to know
- **Existing perpetual mappings.** Subscription purchases still issue on a perpetual plan. Events that identify a subscription show a warning; cancellation does not revoke lifetime access. Existing perpetual licenses remain perpetual on renewal. Choose a finite plan for access that should expire, and review [[guides/sales-platform-operations|subscription access and recovery]] before changing existing mappings.
- **Payment Links only.** Matching is done on the Payment Link ID that Stripe puts on the Checkout Session. Sessions created any other way (your own Checkout integration, invoices, the Dashboard's manual payments) never issue a license.
- **Subscriptions.** Map a recurring price to a **finite** license plan whose duration matches your billing interval. Checkout initially grants that plan duration, including for free trials; it does not synchronize Stripe trial dates or the first invoice period. Subsequent `invoice.paid` renewal events use the matching subscription line period end. Cancellation and failed payment do not change the license: it expires at its stored expiry. Configure trial/initial-period access deliberately and verify it in your rehearsal.
- **Refunding a subscription payment revokes.** A refund of any invoice of a subscription, the first one or a renewal, is matched to the subscription and revokes its license. If you only meant to refund one period as a courtesy, reissue the license afterwards from LicenseSeat.
- **Chargebacks.** A dispute revokes the license the moment Stripe reports it, before the outcome is known. If you win the dispute, reissue the license from LicenseSeat.
- **Delayed payment methods.** For bank debits and vouchers Stripe fires `checkout.session.completed` while the session is still unpaid. LicenseSeat issues nothing then, and issues the license when `checkout.session.async_payment_succeeded` arrives days later, which is why that event is in the list.
- **Retries and duplicates.** Stripe retries a failed delivery for up to three days in live mode and may deliver an event more than once, in any order. LicenseSeat answers immediately, processes in the background, and recognises duplicates by Stripe's event ID and by the Checkout Session, so a purchase is never fulfilled twice. Renewals missing their purchase history have finite automatic retries. An unmatched refund/dispute is ignored but retained; the recovery sweep resumes it only when history resolves to a license. A later purchase also checks retained reversals before emailing an active key. See the recovery guide; an HTTP 200 means receipt, not completed fulfillment.
- **Thank-you page.** Optionally, redirect buyers to a LicenseSeat page that shows their key right after paying: on the connection page open **Hosted Thank You Page**, copy the URL, then in Stripe edit the Payment Link, go to the **After the payment** tab, choose **Don't show confirmation page** and paste the URL. The `{CHECKOUT_SESSION_ID}` part is filled in by Stripe.
## Troubleshooting
- **The connection stays "Incomplete".** The signing secret is missing, or no Payment Link is linked yet. Both are on the connection page.
- **The event shows as "ignored" with "no active mapping".** The session's Payment Link ID does not match any linked link. Open the link in Stripe's Payment Links page and compare its `plink_` ID with what you entered. A `buy.stripe.com` address is not an ID.
- **The event shows as "ignored" and the payment did not come from a Payment Link.** Expected. See "Payment Links only" above.
- **Stripe shows a failed delivery with status 400.** The signing secret does not match. Open the endpoint in Workbench → Webhooks, click **Reveal secret** and paste it again. A sandbox secret will not verify live events, and a secret printed by `stripe listen` will not verify Dashboard-created endpoints.
- **Licenses stop working after the first year or month.** The endpoint is not subscribed to `invoice.paid`. In Workbench → Webhooks open the endpoint, add the event, and the next renewal will extend the license. Renewals that already happened can be fixed by extending the license by hand.
- **A renewal event shows as "failed".** The subscription was not created through a linked Payment Link, so there is no license to extend. Issue the license by hand; future renewals still cannot be matched.
- **A refund did not revoke.** Open the event in LicenseSeat. On Basil and later, enable `invoice_payment.paid` and resend both that relationship event and the relevant `invoice.paid`/Checkout history, then retry the reversal. A missing relationship leaves the reversal ignored with a no-matching-license explanation; it remains recoverable when the relationship arrives. If history is unavailable, revoke manually. A manually issued replacement does not automatically acquire billing provenance.
## References
These are Stripe's own pages. If a label or menu in the Stripe Dashboard ever differs from this guide, they are the source of truth:
- [Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks) — creating an endpoint from Workbench's Webhooks tab, revealing the `whsec_` signing secret, signature verification, retries (three days in live mode), duplicate and out-of-order delivery, rolling secrets.
- [Manage event destinations](https://docs.stripe.com/workbench/event-destinations) — the destination wizard step by step, viewing event deliveries, manual retries.
- [How Workbench works](https://docs.stripe.com/workbench/overview) — opening Workbench (Developers menu, `~` shortcut), the Webhooks and Events tabs, "Resend" on a delivery.
- [Add a webhook endpoint (legacy Developers Dashboard)](https://docs.stripe.com/development/dashboard/webhooks) — the old flow ending in "Add endpoint", with Stripe's note that Workbench replaces it.
- [Resolve webhook signature verification errors](https://docs.stripe.com/webhooks/signature) — wrong secret, sandbox vs live, CLI vs Dashboard secrets, altered request body.
- [Types of events](https://docs.stripe.com/api/events/types) — one-line definitions of `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `invoice.payment_succeeded`, `charge.refunded`, `charge.dispute.created`.
- [Fulfill orders (Checkout and Payment Links)](https://docs.stripe.com/checkout/fulfillment) — why webhooks are required, `checkout.session.completed` vs `async_payment_succeeded`, the `payment_status` check, the `{CHECKOUT_SESSION_ID}` placeholder and the "After Payment" tab.
- [After a payment link payment](https://docs.stripe.com/payment-links/post-payment) — `checkout.session.completed` for Payment Links, the "After the payment" confirmation settings, refunding a payment from the Payments page.
- [Create a payment link](https://docs.stripe.com/payment-links/create) — the Payment Links page, recurring prices, the note that bank debits and vouchers take 2–14 days.
- [Share a payment link](https://docs.stripe.com/payment-links/share) — the details page, the copy icon, QR code and Buy button.
- [Create an embeddable buy button](https://docs.stripe.com/payment-links/buy-button) — buy buttons are generated from a Payment Link and send `checkout.session.completed`.
- [The Payment Link object](https://docs.stripe.com/api/payment-link/object) — the `plink_` id versus the shareable `url`.
- [The Checkout Session object](https://docs.stripe.com/api/checkout/sessions/object) — `payment_link` ("The ID of the Payment Link that created this Session"), `customer_details`, `payment_intent`, `payment_status`, `subscription`.
- [The Invoice object](https://docs.stripe.com/api/invoices/object) — `billing_reason` values, including `subscription_cycle` and `subscription_create`.
- [The Dispute object](https://docs.stripe.com/api/disputes/object) — `charge` and `payment_intent` on a dispute.
- [Using webhooks with subscriptions](https://docs.stripe.com/billing/subscriptions/webhooks) — `invoice.paid` as the renewal signal, tracking access expiry, refund-to-subscription correlation.
- [How disputes work](https://docs.stripe.com/disputes/how-disputes-work) — the dispute lifecycle and how Stripe notifies you.
- [Refund and cancel payments](https://docs.stripe.com/refunds) — refunding from the Dashboard, partial refunds, refund events.
- [Sandboxes](https://docs.stripe.com/sandboxes) — isolated test environments, switching from the account picker.
- [Test cards](https://docs.stripe.com/testing) — `4242 4242 4242 4242` and the cards that simulate disputes.
- [Stripe CLI: trigger](https://docs.stripe.com/cli/trigger) — what `stripe trigger` creates; its `checkout.session.completed` fixture builds a session without a Payment Link.
- [Basil invoice-payment breaking change](https://docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices) — removal of `charge.invoice` and invoice payment pointers.
- [Invoice Payment object](https://docs.stripe.com/api/invoice-payment/object) — the supported payment-to-invoice bridge; do not assume `invoice.payments` is expanded.
---
## C++ SDK
Official C++ SDK for LicenseSeat. Add license validation to native applications, Unreal Engine games, and VST/AU audio plugins.
> **Current stable release:** [v0.7.0](https://github.com/licenseseat/licenseseat-cpp/releases/tag/v0.7.0). Pin this tag in production and verify downloaded release assets against the published `SHA256SUMS` file.
SynthDemo: FREE tier with PRO feature gating and license activation
> **Building a VST plugin or Unreal game?** Use the [JUCE adapters](#juce-vst-au-aax), which require the core SDK and OpenSSL, or the native [Unreal Engine plugin](#unreal-engine-plugin) for online licensing.
## Features
- **License activation & deactivation** - Automatic device fingerprinting
- **Online & offline validation** - Fingerprint-aware validation with signed offline artifacts
- **Machine files** - AES-256-GCM + Ed25519 machine files for preferred offline validation
- **Entitlement checking** - `has_entitlement()` and `check_entitlement()`
- **Local caching** - File-based caching with clock tamper detection
- **Auto-validation** - Background validation at configurable intervals
- **Event system** - Subscribe to license events
- **Thread-safe** - All public methods safe from any thread
- **Exception-free** - Uses `Result` pattern
## Installation
### CMake (FetchContent)
```cmake
include(FetchContent)
FetchContent_Declare(
licenseseat
GIT_REPOSITORY https://github.com/licenseseat/licenseseat-cpp.git
GIT_TAG v0.7.0
)
FetchContent_MakeAvailable(licenseseat)
target_link_libraries(your_target PRIVATE licenseseat::licenseseat)
```
### Manual Build
```bash
git clone --branch v0.7.0 --depth 1 https://github.com/licenseseat/licenseseat-cpp.git
cd licenseseat-cpp
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
sudo cmake --install build
```
### Dependencies
**All bundled (no installation needed):**
- nlohmann/json – JSON parsing
- cpp-httplib – HTTP client
- PicoSHA2 – SHA-256 hashing
**External (the only thing you need to install):**
- OpenSSL – for HTTPS, Ed25519 verification, and machine-file AES-256-GCM in the full SDK path
```bash
# Ubuntu/Debian
sudo apt install libssl-dev
# macOS (usually pre-installed)
brew install openssl
# Windows
vcpkg install openssl
```
On macOS, the CMake target links `Security.framework` automatically. Direct
consumers of the generated single header must link it themselves so TLS can
use roots from the system Keychain.
That's it. Clone and build — no other dependencies to install.
### Release Assets
[C++ SDK v0.7.0](https://github.com/licenseseat/licenseseat-cpp/releases/tag/v0.7.0)
ships installable macOS ARM64, macOS Intel, Linux x64, and Windows x64
archives, plus `licenseseat_single.hpp` and `SHA256SUMS`. The generated single
header still requires compatible nlohmann/json and cpp-httplib headers, plus
OpenSSL for HTTPS and cryptographic verification. Both JUCE adapters require the core SDK and OpenSSL. The native Unreal plugin
provides online licensing through Unreal's own HTTP and JSON modules.
## Quick Start
```cpp
#include
int main()
{
licenseseat::Config config;
config.api_key = "pk_live_xxxxxxxx";
config.product_slug = "your-product";
licenseseat::Client client(config);
// Validate a license
auto result = client.validate("XXXX-XXXX-XXXX-XXXX");
if (result.is_ok()) {
const auto& validation = result.value();
if (validation.valid) {
std::cout << "License is valid!\n";
std::cout << "Plan: " << validation.license.plan_key() << "\n";
} else {
std::cout << "Invalid: " << validation.code << " - " << validation.message << "\n";
}
} else {
std::cerr << "Error: " << result.error_message() << "\n";
}
// Check entitlements
if (client.has_entitlement("pro")) {
// Enable pro features
}
return 0;
}
```
## Integration Guide
Step-by-step guide to integrating LicenseSeat into your C++ application.
### 1. Get Your Credentials
From your [LicenseSeat Dashboard](https://licenseseat.com/dashboard):
1. **API Key** — Go to Settings → API Keys → Copy a restricted `pk_live_*` client key. Assume any key embedded in a shipped binary can be extracted.
2. **Product Slug** — Go to Products → Click your product → Copy the slug from the URL
> **Important:** Use only a least-privilege `pk_*` client key in an application.
> Keep `sk_*`, administrator, and server-write keys on trusted servers. Treat
> end-user license keys as secrets too: keep them out of URLs, logs, crash
> reports, analytics, and API responses.
### 2. Add the SDK to Your Project
**Option A: CMake FetchContent (recommended)**
```cmake
include(FetchContent)
FetchContent_Declare(
licenseseat
GIT_REPOSITORY https://github.com/licenseseat/licenseseat-cpp.git
GIT_TAG v0.7.0
)
FetchContent_MakeAvailable(licenseseat)
target_link_libraries(your_target PRIVATE licenseseat::licenseseat)
```
**Option B: Manual**
```bash
git clone --branch v0.7.0 --depth 1 https://github.com/licenseseat/licenseseat-cpp.git
cd licenseseat-cpp
cmake -B build && cmake --build build
sudo cmake --install build
```
### 3. Initialize the Client
```cpp
#include
licenseseat::Config config;
config.api_key = "pk_live_xxxxxxxx"; // Your publishable key
config.product_slug = "your-product"; // From dashboard
config.storage_path = "/path/to/cache"; // Optional: enables persistence
config.app_version = "1.0.0"; // Your app version
licenseseat::Client client(config);
```
### 4. Validate the License
```cpp
auto result = client.validate("XXXX-XXXX-XXXX-XXXX");
if (result.is_ok() && result.value().valid) {
const auto& license = result.value().license;
std::cout << "Plan: " << license.plan_key() << "\n";
// Store the key for future use
save_license_key("XXXX-XXXX-XXXX-XXXX");
} else if (result.is_ok()) {
// License exists but invalid
std::cout << "License issue code: " << result.value().code << "\n";
} else {
// Network or API error
std::cerr << "Error: " << result.error_message() << "\n";
}
```
### 5. Activate the Device (Optional)
For hardware-locked licenses, activate the device to consume a seat:
```cpp
std::string device_id = licenseseat::generate_device_id(); // Auto-generated fingerprint
std::string device_name = "User's MacBook Pro"; // Friendly name
auto result = client.activate("LICENSE-KEY", device_id, device_name);
if (result.is_ok()) {
std::cout << "Activated on device: " << result.value().device_name() << "\n";
} else if (result.error_code() == licenseseat::ErrorCode::SeatLimitExceeded) {
std::cout << "No seats available. Deactivate another device first.\n";
}
```
### 6. Check Features (Entitlements)
Gate features based on the license plan:
```cpp
// Simple boolean check
if (client.has_entitlement("pro-features")) {
enable_pro_mode();
}
if (client.has_entitlement("export-pdf")) {
show_export_button();
}
// Detailed check with expiration info
auto status = client.check_entitlement("beta-access");
if (status.active) {
if (status.expires_at) {
show_beta_with_countdown(*status.expires_at);
} else {
show_beta_perpetual();
}
}
```
### 7. Enable Offline Support (Optional)
The current C++ SDK is machine-file-first. Set a cache path, explicitly enable
bounded network-only fallback, activate online once, and the SDK automatically
syncs an encrypted machine file for offline use:
```cpp
licenseseat::Config config;
config.api_key = "pk_live_xxxxxxxx";
config.product_slug = "your-product";
config.storage_path = "/path/to/cache";
config.offline_fallback_mode = licenseseat::OfflineFallbackMode::NetworkOnly;
config.max_offline_days = 7;
licenseseat::Client client(config);
auto activation = client.activate("LICENSE-KEY");
if (activation.is_ok()) {
std::cout << "Activated and machine file cached\n";
}
// Later, when offline:
auto restore = client.restore_license();
if (restore.success && restore.status == licenseseat::ClientStatus::OfflineValid) {
std::cout << "Restored from cached machine file\n";
}
```
If you need full manual control, fetch and verify the machine file explicitly:
```cpp
client.activate("LICENSE-KEY"); // Required once for the same fingerprint
auto machine_file = client.checkout_machine_file("LICENSE-KEY").value();
auto verify = client.verify_machine_file(machine_file);
if (verify.is_ok() && verify.value().valid) {
std::cout << "Offline license verified from machine file\n";
}
```
Legacy offline tokens still exist for older integrations, but they are no longer the default path. Enable them explicitly only if you still depend on them:
```cpp
// Keep the pinned key, storage and offline policy configured above.
// Set this before constructing the Client.
config.enable_legacy_offline_tokens = true;
```
### 8. Handle License Changes
Subscribe to events for reactive updates:
```cpp
client.on(licenseseat::events::VALIDATION_SUCCESS, [](const std::any& data) {
update_ui_licensed();
});
client.on(licenseseat::events::VALIDATION_FAILED, [](const std::any& data) {
show_license_expired_dialog();
});
// Start background validation (every hour)
client.start_auto_validation("LICENSE-KEY");
```
### Complete Example
```cpp
#include
#include
int main() {
// 1. Configure
licenseseat::Config config;
config.api_key = "pk_live_xxxxxxxx";
config.product_slug = "my-desktop-app";
config.storage_path = get_app_data_path() + "/license_cache";
config.app_version = "2.0.0";
licenseseat::Client client(config);
// 2. Load saved license key (if any)
std::string license_key = load_saved_license_key();
if (license_key.empty()) {
license_key = show_license_entry_dialog();
}
// 3. Validate
auto result = client.validate(license_key);
if (result.is_ok() && result.value().valid) {
save_license_key(license_key);
// 4. Check what features they have
bool is_pro = client.has_entitlement("pro");
bool has_export = client.has_entitlement("export");
// 5. Launch app with appropriate features
launch_app(is_pro, has_export);
// 6. Keep validating in background
client.start_auto_validation(license_key);
} else {
show_license_invalid_dialog(
result.is_ok() ? result.value().message : result.error_message()
);
}
return 0;
}
```
## Metadata Access
Metadata lets you attach custom key-value data to licenses and entitlements. Use it for customer info, feature limits, configuration, or any app-specific data.
### License Metadata
Access metadata from the license object after validation:
```cpp
auto result = client.validate("LICENSE-KEY");
if (result.is_ok() && result.value().valid) {
const auto& license = result.value().license;
const auto& metadata = license.metadata(); // std::map
// Access specific keys
if (metadata.count("customer_id")) {
std::string customer_id = metadata.at("customer_id");
load_customer_preferences(customer_id);
}
if (metadata.count("max_projects")) {
int max_projects = std::stoi(metadata.at("max_projects"));
enforce_project_limit(max_projects);
}
// Iterate all metadata
for (const auto& [key, value] : metadata) {
std::cout << key << " = " << value << "\n";
}
}
```
### Entitlement Metadata
Each entitlement can have its own metadata:
```cpp
auto result = client.validate("LICENSE-KEY");
if (result.is_ok() && result.value().valid) {
// Get all active entitlements
for (const auto& ent : result.value().license.active_entitlements()) {
std::cout << "Entitlement: " << ent.key << "\n";
// Access entitlement-specific metadata
for (const auto& [key, value] : ent.metadata) {
std::cout << " " << key << " = " << value << "\n";
}
// Example: Feature limits per entitlement
if (ent.key == "api-access" && ent.metadata.count("rate_limit")) {
int rate_limit = std::stoi(ent.metadata.at("rate_limit"));
configure_api_rate_limit(rate_limit);
}
}
}
```
### Offline Metadata
Metadata is included in machine files too:
```cpp
auto machine_file = client.checkout_machine_file("LICENSE-KEY").value();
auto verify = client.verify_machine_file(machine_file);
if (verify.is_ok() && verify.value().valid && verify.value().payload.has_value()) {
const auto& payload = *verify.value().payload;
// Activation metadata captured in the machine file
if (payload.metadata.count("device_name")) {
std::cout << "Device name: " << payload.metadata.at("device_name") << "\n";
}
// Embedded license metadata and entitlements
if (payload.license.has_value()) {
const auto& license = *payload.license;
if (license.metadata().count("customer_name")) {
std::cout << "Licensed to: " << license.metadata().at("customer_name") << "\n";
}
}
}
```
If you still rely on legacy offline tokens, metadata is accessible there too through `offline_token.token.metadata` and `offline_token.token.entitlements`.
### Common Metadata Use Cases
| Use Case | Metadata Key | Example Value |
|----------|--------------|---------------|
| Customer identification | `customer_id` | `"cust_123abc"` |
| Feature limits | `max_projects` | `"10"` |
| API rate limits | `rate_limit` | `"1000"` |
| White-label branding | `company_name` | `"Acme Corp"` |
| Seat allocation | `assigned_seats` | `"5"` |
| Custom expiration | `support_expires` | `"2026-12-31"` |
> **Tip:** Set metadata when creating licenses via the Dashboard or API. The SDK only reads metadata — modifications require admin access.
## Configuration
```cpp
licenseseat::Config config;
// Required
config.api_key = "pk_live_xxxxxxxx";
config.product_slug = "your-product";
// Optional - API settings
config.api_url = "https://licenseseat.com/api/v1"; // Default
config.timeout_seconds = 30;
config.max_retries = 3;
// Optional - Device identification
config.device_id = ""; // Legacy config name; auto-generates the device fingerprint if empty
// Optional - App info (for telemetry)
config.app_version = "2.1.0";
config.app_build = "42";
// Optional - Offline support
config.signing_public_key = "base64-ed25519-public-key"; // Pre-configure machine-file verification
config.offline_fallback_mode = licenseseat::OfflineFallbackMode::NetworkOnly;
config.max_offline_days = 30;
// Optional - Caching
config.storage_path = ""; // Path for license cache (empty = no persistence)
// Optional - Auto-validation & heartbeat
config.auto_validate_interval = 3600.0; // Seconds between background validations
config.heartbeat_interval = 300; // Seconds between heartbeats (5 minutes)
```
### Configuration Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `api_key` | `string` | *required* | Your publishable API key |
| `product_slug` | `string` | *required* | Product identifier |
| `api_url` | `string` | `https://licenseseat.com/api/v1` | API endpoint |
| `timeout_seconds` | `int` | `30` | HTTP request timeout |
| `max_retries` | `int` | `3` | Retry attempts for failed requests |
| `device_id` | `string` | `""` | Device fingerprint (legacy config name, auto-generated if empty) |
| `signing_public_key` | `string` | `""` | Ed25519 public key for machine files and legacy offline tokens |
| `offline_fallback_mode` | `OfflineFallbackMode` | `Disabled` | Automatic offline authority. `NetworkOnly` is eligible only after a network/transport failure. |
| `max_offline_days` | `int` | `0` | Local offline restore limit in days. `0` disables offline authority. |
| `app_version` | `string` | `""` | Your app version for telemetry |
| `app_build` | `string` | `""` | Your app build number for telemetry |
| `storage_path` | `string` | `""` | Path for license cache (empty = no persistence) |
| `auto_validate_interval` | `double` | `3600.0` | Seconds between auto-validation cycles |
| `heartbeat_interval` | `int` | `300` | Seconds between standalone heartbeats (0 = disabled) |
Automatic offline authority is fail-closed unless the fallback mode is enabled
and `max_offline_days` is positive. `Always` is a deprecated compatibility
alias for `NetworkOnly`; every received HTTP response—including 408, 4xx, and
5xx—and every local protocol or security failure remains authoritative.
## Validation
Validation checks whether a license is valid. A successfully parsed validation
decision uses HTTP 200; authentication, transport, server, or protocol failures
may not. First check the outer `Result`, then check the response's `valid`
field.
```cpp
auto result = client.validate("LICENSE-KEY");
if (result.is_ok()) {
const auto& validation = result.value();
if (validation.valid) {
// License is valid and usable
std::cout << "Valid! Plan: " << validation.license.plan_key() << "\n";
} else {
// License exists but isn't valid for use
// Common codes: expired, revoked, suspended, seat_limit_exceeded
std::cout << "Code: " << validation.code << "\n";
std::cout << "Message: " << validation.message << "\n";
}
// License data is always available (even when invalid)
const auto& license = validation.license;
std::cout << "Status: " << license_status_to_string(license.status()) << "\n";
std::cout << "Seats: " << license.active_seats() << "/" << license.seat_limit() << "\n";
} else {
// API error (license not found, network error, auth failed)
std::cerr << "Validation request failed\n";
}
```
> **Note:** For **hardware-locked** licenses, you must provide a device fingerprint. In the current public C++ API that parameter is still named `device_id` for compatibility:
> ```cpp
> auto result = client.validate("LICENSE-KEY", device_id);
> ```
> Without it, validation may return `valid: false` with code `device_not_activated`.
### Async Validation
```cpp
client.validate_async("LICENSE-KEY", [](licenseseat::Result result) {
if (result.is_ok() && result.value().valid) {
// License is valid
}
});
```
## Activation
In v0.7.0, both `Activation::id()` and `Deactivation::activation_id` are strings.
Update integer variables and remove `std::to_string` calls around these fields.
Hosted responses use UUIDs; positive integer identifiers from self-hosted engines
are accepted and normalized without signed overflow.
Activation binds a license to a device, consuming a seat.
```cpp
auto result = client.activate("LICENSE-KEY", device_id, "My MacBook Pro");
if (result.is_ok()) {
const auto& activation = result.value();
std::cout << "Activation ID: " << activation.id() << "\n";
std::cout << "Device: " << activation.device_name() << "\n";
} else {
switch (result.error_code()) {
case licenseseat::ErrorCode::SeatLimitExceeded:
std::cerr << "No seats available\n";
break;
case licenseseat::ErrorCode::DeviceAlreadyActivated:
std::cerr << "Device already activated\n";
break;
default:
std::cerr << "Error: " << result.error_message() << "\n";
}
}
```
## Deactivation
Deactivation removes a device from a license, freeing a seat.
```cpp
auto result = client.deactivate("LICENSE-KEY", device_id);
if (result.is_ok()) {
std::cout << "Device deactivated\n";
} else if (result.error_code() == licenseseat::ErrorCode::ActivationNotFound) {
std::cout << "Device was not activated\n";
}
```
## Entitlements
Entitlements are feature flags tied to a license. They allow you to gate specific features without creating separate products.
### Simple Check (Boolean)
```cpp
// Uses cached license data - no network request
if (client.has_entitlement("pro-features")) {
enable_pro_features();
}
if (client.has_entitlement("beta-access")) {
show_beta_ui();
}
```
### Detailed Check
```cpp
auto status = client.check_entitlement("pro-features");
if (status.active) {
// Entitlement is active
std::cout << "Feature unlocked!\n";
// Check expiration (if set)
if (status.expires_at) {
auto expires = *status.expires_at;
auto now = std::chrono::system_clock::now();
auto days_left = std::chrono::duration_cast(expires - now).count() / 24;
std::cout << "Expires in " << days_left << " days\n";
} else {
std::cout << "Never expires (perpetual)\n";
}
// Access metadata if needed
if (status.entitlement) {
for (const auto& [key, value] : status.entitlement->metadata) {
std::cout << " " << key << ": " << value << "\n";
}
}
} else {
// Entitlement not active - check reason
if (status.reason == "no_license") {
show_activation_screen();
} else if (status.reason == "not_found") {
show_upgrade_prompt(); // Feature not in their plan
} else if (status.reason == "expired") {
show_renewal_prompt();
}
}
```
### EntitlementStatus Fields
| Field | Type | Description |
|-------|------|-------------|
| `active` | `bool` | Whether the entitlement is currently active |
| `reason` | `string` | Why inactive: `no_license`, `not_found`, `expired` |
| `expires_at` | `optional` | Expiration time (empty if perpetual) |
| `entitlement` | `optional` | Full entitlement data if found |
### Entitlements in Validation Response
After validation, entitlements are available on the license object:
```cpp
auto result = client.validate("LICENSE-KEY");
if (result.is_ok() && result.value().valid) {
const auto& license = result.value().license;
std::cout << "Active entitlements:\n";
for (const auto& ent : license.active_entitlements()) {
std::cout << " - " << ent.key;
if (ent.expires_at) {
std::cout << " (expires: " << *ent.expires_at << ")";
} else {
std::cout << " (perpetual)";
}
std::cout << "\n";
}
}
```
### Offline Entitlement Checking
Entitlements are included in offline machine files too:
```cpp
auto machine_file = client.checkout_machine_file("LICENSE-KEY").value();
auto verify = client.verify_machine_file(machine_file);
if (verify.is_ok() && verify.value().valid && verify.value().payload.has_value()) {
const auto& payload = *verify.value().payload;
if (payload.license.has_value()) {
for (const auto& ent : payload.license->active_entitlements()) {
std::cout << "Entitlement: " << ent.key << "\n";
}
}
if (client.check_entitlement("pro-features").active) {
enable_pro_features();
}
}
```
If you still rely on legacy offline tokens, you can read entitlements from `offline_token.token.entitlements` too.
See [[entitlements|Entitlements]] for more details on setting up and managing entitlements.
## Status
Get the current cached license status without making a network request.
```cpp
auto status = client.get_status();
std::cout << "Valid: " << (status.valid ? "yes" : "no") << "\n";
std::cout << "Code: " << status.code << "\n";
```
## Check API Health
Check the public API's reachability and health:
```cpp
auto result = client.health();
if (result.is_ok()) {
std::cout << "API is reachable and healthy\n";
} else {
std::cerr << "Health check failed: " << result.error_message() << "\n";
}
```
Returns `Result` where a successful result indicates that the public API
is reachable. The health request deliberately omits bearer authentication, so
it does **not** prove that an API key is present, valid, scoped correctly, or
authorized for the product.
## Offline Support
The SDK supports offline validation with encrypted machine files signed using Ed25519. Machine files are the preferred path for new integrations because they are:
- Activation-bound
- Fingerprint-bound
- AES-256-GCM encrypted
- Signed for tamper detection
The public signing key is part of the trust decision. For offline authority
after process restart, configure the expected key through trusted application
configuration. A key fetched over authenticated transport is suitable for the
current process, but a key recovered only from attacker-writable cache storage
is deliberately not accepted as a trust anchor.
### Preferred Workflow: Machine Files
Pin your organization's key in trusted application configuration. Fetch it during
integration from `GET /api/v1/signing_keys/{key_id}` and use `public_key` exactly as
returned: Base64 encoding of 32 raw Ed25519 bytes, not PEM or DER. The endpoint
needs no authentication. Keep the key and its matching ID with the application;
a key supplied by the user alongside a certificate cannot establish trust.
```cpp
licenseseat::Config config;
config.api_key = "YOUR_PUBLISHABLE_KEY";
config.product_slug = "your-product";
config.storage_path = "/path/to/cache";
config.signing_public_key = "YOUR_BASE64_RAW_ED25519_PUBLIC_KEY";
config.signing_key_id = "YOUR_ORGANIZATION_SIGNING_KEY_ID";
config.offline_fallback_mode = licenseseat::OfflineFallbackMode::NetworkOnly;
config.max_offline_days = 7;
licenseseat::Client client(config);
auto activated = client.activate("LICENSE-KEY");
if (activated.is_error()) throw std::runtime_error(activated.error_message());
// The automatic sync is best effort. Check checkout before claiming offline readiness.
auto machine = client.checkout_machine_file("LICENSE-KEY");
if (machine.is_error()) throw std::runtime_error(machine.error_message());
```
On the next application run, construct a fresh Client with the same configuration,
cache path and fingerprint, then call `restore_license()`. Check `success`;
`OfflineValid` means the signed cached artifact was verified without server validation.
Successful local activation saves the session identity as of v0.7.0. Earlier SDKs
required a successful online `validate()` call before session restore.
```cpp
licenseseat::Client restarted(config);
auto restored = restarted.restore_license();
if (restored.success) {
std::cout << restored.message << "\n";
}
```
Setting only `storage_path` does not enable offline support. Both an enabled fallback
mode and a positive `max_offline_days` are required. Certificate and license expiry
can shorten that duration. Keys fetched by a Client are retained only in that
process; use the application pin for a later offline startup.
### Importing a machine file from curl
Collect the target machine's exact `client.fingerprint()`. On an online helper,
activate the license for that fingerprint, then request `/licenses/machine-file`
using the SAME fingerprint and `"include": ["license"]`. Transfer the value at
`data.attributes.certificate` to the offline target. Do not use the online helper's
fingerprint or the documentation's example fingerprint.
```cpp
licenseseat::Client client(config); // trusted key pin from the configuration above
licenseseat::MachineFile imported;
imported.certificate = certificate_text;
auto verified = client.verify_machine_file(imported, "", license_key, client.fingerprint());
if (verified.is_error()) throw std::runtime_error(verified.error_message());
if (!verified.value().valid || !verified.value().payload)
throw std::runtime_error(verified.value().message);
const auto& payload = *verified.value().payload;
if (payload.license) {
std::cout << payload.license->plan_key() << "\n";
}
```
This public API needs neither a private key-ID parser nor a preexisting SDK cache.
It never fetches keys or contacts the network. Direct verification does not import a
session for `restore_license()`; custom-storage apps reload and verify the certificate
on each startup. Missing `include` leaves `payload.license` empty. Wrong keys,
fingerprints, expired claims and modified certificates are rejected.
### Legacy Offline Tokens
Legacy offline tokens are still available for compatibility, but only use them when you explicitly need portable JSON tokens for an older integration:
```cpp
config.enable_legacy_offline_tokens = true;
client.activate("LICENSE-KEY"); // Offline tokens also require an existing activation
auto token = client.generate_offline_token("LICENSE-KEY").value();
std::string token_json = licenseseat::json::offline_token_to_json(token);
```
### Machine File Payload
| Field | Type | Description |
| ----- | ---- | ----------- |
| `license_key` | `string` | License key embedded in the encrypted payload |
| `fingerprint` | `string` | Device fingerprint the machine file was issued for |
| `fingerprint_components` | `object` | Structured fingerprint metadata captured during checkout |
| `iat` / `exp` / `nbf` | `int64` | Issued-at, expiry, and not-before timestamps |
| `ttl` | `int64` | Machine-file lifetime in seconds |
| `grace_period` | `int64` | Extra offline grace after expiry |
| `kid` | `string` | Signing key ID |
| `license` | `object` | Embedded license snapshot with entitlements and metadata |
### Long-Lived Offline Deployments
Machine files are finite by design, but the server can intentionally allow long TTLs for deployments that are rarely connected:
- consumer apps: usually 30-45 days
- professional/field deployments: often 90-365 days
- intentionally air-gapped systems: sometimes multi-year TTLs
The automatic fallback/restore policy is capped at 30 days in this SDK, regardless
of a longer server-issued TTL. A longer certificate TTL does not override
`max_offline_days`. Direct certificate verification is a lower-level API: apps
choosing it must define their own permitted offline window. Longer disconnected
periods delay visibility of revocations and entitlement changes.
## Auto-Validation
Background validation at configurable intervals.
```cpp
// Configure interval (in seconds)
config.auto_validate_interval = 3600.0; // Every hour
licenseseat::Client client(config);
// Start auto-validation
client.start_auto_validation("LICENSE-KEY");
// Check if running
if (client.is_auto_validating()) {
std::cout << "Auto-validation is active\n";
}
// Stop when done
client.stop_auto_validation();
```
## Events
Subscribe to license events for reactive updates.
```cpp
#include
// Subscribe to validation success
auto sub1 = client.on(licenseseat::events::VALIDATION_SUCCESS, [](const std::any& data) {
std::cout << "License validated successfully!\n";
});
// Subscribe to validation failure
auto sub2 = client.on(licenseseat::events::VALIDATION_FAILED, [](const std::any& data) {
std::cout << "License validation failed\n";
});
// Subscribe to machine-file ready
auto sub3 = client.on(licenseseat::events::MACHINE_FILE_READY, [](const std::any& data) {
std::cout << "Machine file cached\n";
});
// Later: cancel subscriptions
sub1.cancel();
sub2.cancel();
sub3.cancel();
```
### Available Events
| Event | Description |
| ----- | ----------- |
| `LICENSE_LOADED` | License data loaded from cache |
| `ACTIVATION_START` | Activation request starting |
| `ACTIVATION_SUCCESS` | Device activated successfully |
| `ACTIVATION_ERROR` | Activation failed |
| `VALIDATION_START` | Validation request starting |
| `VALIDATION_SUCCESS` | License validated successfully |
| `VALIDATION_FAILED` | License validation returned invalid |
| `VALIDATION_ERROR` | Validation request failed (network, etc.) |
| `VALIDATION_OFFLINE_SUCCESS` | Cached machine file (or legacy token) verified successfully |
| `VALIDATION_OFFLINE_FAILED` | Offline verification failed |
| `DEACTIVATION_START` | Deactivation request starting |
| `DEACTIVATION_SUCCESS` | Device deactivated successfully |
| `DEACTIVATION_ERROR` | Deactivation failed |
| `NETWORK_ONLINE` | Network connectivity restored |
| `NETWORK_OFFLINE` | Network connectivity lost |
| `HEARTBEAT_SUCCESS` | Heartbeat acknowledged by server |
| `HEARTBEAT_ERROR` | Heartbeat request failed |
| `AUTOVALIDATION_CYCLE` | Auto-validation cycle completed |
| `AUTOVALIDATION_STOPPED` | Auto-validation stopped |
| `MACHINE_FILE_READY` | Machine file cached |
| `MACHINE_FILE_VERIFIED` | Machine file verified |
| `OFFLINE_TOKEN_READY` | Legacy offline token generated |
| `OFFLINE_TOKEN_VERIFIED` | Legacy offline token verified |
| `SDK_RESET` | SDK state reset |
## Error Handling
The SDK uses a `Result` pattern instead of exceptions.
```cpp
auto result = client.validate("LICENSE-KEY");
if (result.is_ok()) {
auto& validation = result.value();
// Success - check validation.valid for license validity
} else {
// Error - network, auth, or API error
std::cerr << "Error: " << result.error_message() << "\n";
switch (result.error_code()) {
case licenseseat::ErrorCode::NetworkError:
// No network connectivity
break;
case licenseseat::ErrorCode::LicenseNotFound:
// Invalid license key
break;
case licenseseat::ErrorCode::AuthenticationFailed:
// Invalid API key
break;
case licenseseat::ErrorCode::SeatLimitExceeded:
// Too many activations
break;
// ... handle other cases
}
}
```
### Error Codes
| Code | Description |
| ---- | ----------- |
| `Success` | Operation completed successfully |
| `NetworkError` | HTTP request failed (no connectivity) |
| `ConnectionTimeout` | Request timed out |
| `SSLError` | SSL/TLS error |
| `InvalidLicenseKey` | License key format is invalid |
| `LicenseNotFound` | License key not found in system |
| `LicenseExpired` | License has expired |
| `LicenseRevoked` | License has been revoked |
| `LicenseSuspended` | License is suspended |
| `LicenseNotActive` | License is not active |
| `LicenseNotStarted` | License hasn't started yet |
| `SeatLimitExceeded` | Maximum activations reached |
| `ActivationNotFound` | Device activation not found |
| `DeviceAlreadyActivated` | Device is already activated |
| `ProductNotFound` | Product slug not found |
| `AuthenticationFailed` | Invalid or missing API key |
| `PermissionDenied` | API key lacks required permissions |
| `ServerError` | Server-side error (5xx) |
| `SigningNotConfigured` | Offline signing not configured |
| `InvalidSignature` | Cryptographic signature invalid |
| `FileError` | File read/write error |
## Telemetry
When `telemetry_enabled` is `true` (the default), the SDK includes the following
optional telemetry on supported licensing requests:
| Field | macOS | Windows | Linux |
|-------|-------|---------|-------|
| `sdk_name` | `cpp` | `cpp` | `cpp` |
| `sdk_version` | Yes | Yes | Yes |
| `os_name` | `macOS` | `Windows` | `Linux` |
| `os_version` | `kern.osproductversion` | `RtlGetVersion` | `uname` |
| `platform` | `native` | `native` | `native` |
| `device_model` | `hw.model` | Registry BIOS | `/sys/class/dmi` |
| `device_type` | `desktop` | `desktop` | `desktop`/`server` |
| `architecture` | `arm64`/`x64` (compile-time) | `arm64`/`x64` | `arm64`/`x64` |
| `cpu_cores` | `hardware_concurrency` | `hardware_concurrency` | `hardware_concurrency` |
| `memory_gb` | `hw.memsize` | `GlobalMemoryStatusEx` | `/proc/meminfo` |
| `locale` | `LANG` env | `LANG` env | `LANG` env |
| `language` | 2-letter code | 2-letter code | 2-letter code |
| `timezone` | `/etc/localtime` | `GetTimeZoneInformation` | `/etc/localtime` |
| `screen_resolution` | `CGDisplay` | `GetSystemMetrics` | DRM subsystem |
| `app_version` | From config | From config | From config |
| `app_build` | From config | From config | From config |
Set `config.telemetry_enabled = false` to omit the optional telemetry object.
Core licensing still sends the license/product context and stable fingerprint,
and the server records successful request activity and the source IP. Machine
fingerprints, raw fingerprint components used for machine-file checkout, and
environment diagnostics can be linked device data; do not describe the flow as
anonymous merely because the canonical fingerprint is hashed.
See [[api-reference/telemetry|Telemetry]] for the full field reference.
## Unreal Engine Plugin
A complete UE plugin using native `FHttpModule` and `FJsonObject`. No external dependencies.
```cpp
auto* LicenseSeat = GetGameInstance()->GetSubsystem();
FLicenseSeatConfig Config;
Config.ApiKey = TEXT("pk_live_xxxxxxxx");
Config.ProductSlug = TEXT("your-game");
LicenseSeat->InitializeWithConfig(Config);
LicenseSeat->ValidateAsync(TEXT("LICENSE-KEY"),
FOnValidationComplete::CreateLambda([](const FLicenseValidationResult& Result)
{
if (Result.bValid)
{
// License valid
}
}));
```
**Location:** `integrations/unreal/LicenseSeat/`
**Features:**
- Blueprint support via `UFUNCTION`/`UPROPERTY`/`USTRUCT`
- `GameInstanceSubsystem` for automatic lifecycle management
- Async API (non-blocking)
- Auto-validation timer
- Online validation, activation and deactivation
The native Unreal plugin does not implement machine-file verification. Use the
core C++ SDK when offline verification is required.
## JUCE: VST / AU / AAX
JUCE-friendly adapters delegate HTTP, JSON, storage and cryptography to the core
C++ SDK. Link `licenseseat::licenseseat` and OpenSSL. The `Standalone` class name
is retained for compatibility; it is not an independent protocol implementation.
```cpp
#include "LicenseSeatJuceStandalone.h"
LicenseSeatJuceStandalone license("pk_live_xxxxxxxx", "your-plugin");
// Audio thread safe (reads std::atomic)
void processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&)
{
if (!license.isValid())
{
buffer.clear();
return;
}
// Process audio
}
// Async validation (callback on message thread)
license.validateAsync("LICENSE-KEY", [](auto& result)
{
if (result.valid)
{
// Update UI
}
});
```
**Location:** `integrations/juce/Source/LicenseSeatJuceStandalone.h`
**Features:**
- Single header file
- `std::atomic` for lock-free status checks in audio thread
- `MessageManager::callAsync` for thread-safe UI callbacks
- Multi-instance safe (no global state)
> **Note:** Both JUCE adapters link the core SDK and OpenSSL. Package and test
> those dependencies with your plugin; the adapter does not remove them.
## Thread Safety
All public methods are thread-safe. The SDK uses internal mutexes to protect shared state.
```cpp
// Safe from multiple threads
std::thread t1([&client]() {
client.validate("KEY");
});
std::thread t2([&client]() {
bool valid = client.is_valid();
});
```
For audio plugins, `isValid()` uses `std::atomic` for lock-free reads in real-time contexts.
## Platform Support
| Platform | Compiler | Status |
| -------- | -------- | ------ |
| Linux | GCC 9+, Clang 10+ | Supported |
| macOS | Apple Clang 12+ (ARM & Intel) | Supported |
| Windows | MSVC 2019+ | Supported |
### Device Identification
The SDK automatically generates a stable fingerprint per device:
- **macOS**: IOKit Platform UUID
- **Windows**: Machine GUID from registry
- **Linux**: `/etc/machine-id`, D-Bus machine ID, DMI product UUID, or hostname fallback
The canonical value sent for seat binding is the first 128 bits of a SHA-256
hash of the preferred signal. When the SDK checks out a machine file using its
default fingerprint, it also sends structured fingerprint components (which can
include the underlying machine identifier and hostname) for server-side matching
and diagnostics. Treat both the hash and those components as device data in the
host application's privacy disclosures and retention policy.
## Reset
Clear all cached data (license, machine files, legacy offline tokens, etc.).
```cpp
client.reset();
```
## Demo App
The SDK includes [SynthDemo](https://github.com/licenseseat/licenseseat-cpp/tree/main/demo), a compact audio synthesizer that demonstrates real-world licensing integration:
- **Feature gating** — FREE tier (sine wave) vs PRO (sawtooth, square, noise)
- **License activation** — Modal dialog with activate/deactivate
- **Session restore** — Cached license on app restart
- **Admin view** — Runtime status, heartbeat, machine file caching, entitlements
Admin view: runtime status, auto-validation, machine file caching, and entitlements
Built with [raylib](https://www.raylib.com/) + raygui. See the [demo README](https://github.com/licenseseat/licenseseat-cpp/tree/main/demo) for build instructions.
## Next Steps
- [[sdks/csharp|C# SDK]] - For .NET applications
- [[sdks/swift|Swift SDK]] - For Apple platforms
- [[guides/offline-licensing|Offline Licensing]] - Air-gapped validation
- [[api-reference/index|API Reference]] - Direct API access
- [C++ SDK on GitHub](https://github.com/licenseseat/licenseseat-cpp) - Source code, issues, and contributions
---
## C# SDK
Official C# SDK for LicenseSeat. Add license validation to your .NET apps, Unity games, and Godot projects in minutes.
> **Current stable release:** [v0.5.0](https://github.com/licenseseat/licenseseat-csharp/releases/tag/v0.5.0), published as [LicenseSeat 0.5.0 on NuGet](https://www.nuget.org/packages/LicenseSeat/0.5.0) and `com.licenseseat.sdk` 0.5.0 on [OpenUPM](https://openupm.com/packages/com.licenseseat.sdk/).
> **Building a Unity game?** Use the dedicated managed Unity package. It
> bundles pinned dependencies, includes C# 9/linker compatibility checks, and
> provides a bounded `UnityWebRequest` transport. Qualify every Unity Editor,
> player, platform, and IL2CPP configuration you actually ship.
> **Important:** Use only a restricted publishable client key scoped to
> `licenses:validate`. Any credential embedded in a desktop, mobile, game, or
> WebGL build can be extracted; never ship an administrator or server-write
> key. Keep end-user license keys out of URLs, logs, crash reports, analytics,
> plaintext persistence, and API responses.
## Installation
### NuGet (.NET, Godot)
```bash
dotnet add package LicenseSeat --version 0.5.0
```
**Requirements:** a runtime implementing .NET Standard 2.0. CI exercises the
package on currently supported .NET SDK/runtime lines; validate older .NET
Framework hosts in your own target environment before shipping.
### Unity
**Option 1: Git URL (Recommended)**
1. Open **Window > Package Manager**
2. Click **+** > **Add package from git URL...**
3. Paste:
```
https://github.com/licenseseat/licenseseat-csharp.git?path=src/LicenseSeat.Unity#v0.5.0
```
**Option 2: manifest.json**
Add to `Packages/manifest.json`:
```json
{
"dependencies": {
"com.licenseseat.sdk": "https://github.com/licenseseat/licenseseat-csharp.git?path=src/LicenseSeat.Unity#v0.5.0"
}
}
```
**Option 3: OpenUPM**
```bash
openupm add com.licenseseat.sdk
```
Unity requires 2021.3 or newer with the .NET Standard 2.1 API compatibility
profile. Keep the Git URL pinned to the reviewed release tag shown above.
## Quick Start
```csharp
using LicenseSeat;
var client = new LicenseSeatClient(new LicenseSeatClientOptions
{
ApiKey = "pk_live_xxxxxxxx",
ProductSlug = "your-product" // Required
});
// Activate a license
var license = await client.ActivateAsync("XXXX-XXXX-XXXX-XXXX");
// Check entitlements
if (client.HasEntitlement("pro-features"))
{
// Enable pro features
}
```
## Static API (Singleton)
For desktop apps where you want global access:
```csharp
using LicenseSeat;
// Configure once at startup
LicenseSeat.LicenseSeat.Configure("pk_live_xxxxxxxx", "your-product", options =>
{
options.AutoValidateInterval = TimeSpan.FromHours(1);
});
// Use anywhere in your app
await LicenseSeat.LicenseSeat.Activate("LICENSE-KEY");
if (LicenseSeat.LicenseSeat.HasEntitlement("premium"))
{
// Premium features
}
var status = LicenseSeat.LicenseSeat.GetStatus();
var license = LicenseSeat.LicenseSeat.GetCurrentLicense();
// Cleanup on exit
LicenseSeat.LicenseSeat.Shutdown();
```
## Configuration
### Basic Configuration
```csharp
var client = new LicenseSeatClient(new LicenseSeatClientOptions
{
ApiKey = "pk_live_xxxxxxxx",
ProductSlug = "your-product"
});
```
### Advanced Configuration
```csharp
var client = new LicenseSeatClient(new LicenseSeatClientOptions
{
ApiKey = "pk_live_xxxxxxxx",
ProductSlug = "your-product",
ApiBaseUrl = "https://licenseseat.com/api/v1",
AutoValidateInterval = TimeSpan.FromHours(1),
HeartbeatInterval = TimeSpan.FromMinutes(5),
AppVersion = "2.1.0",
AppBuild = "42",
MaxRetries = 3,
RetryDelay = TimeSpan.FromSeconds(1),
OfflineFallbackMode = OfflineFallbackMode.NetworkOnly,
MaxOfflineDays = 7,
MaxClockSkew = TimeSpan.FromMinutes(5),
HttpTimeout = TimeSpan.FromSeconds(30),
Debug = true
});
```
### Configuration Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `ApiKey` | `string` | — | **Required.** Your publishable API key |
| `ProductSlug` | `string` | — | **Required.** Your product identifier |
| `ApiBaseUrl` | `string` | `https://licenseseat.com/api/v1` | API endpoint |
| `AutoValidateInterval` | `TimeSpan` | 1 hour | Background validation interval (0 = disabled) |
| `HeartbeatInterval` | `TimeSpan` | 5 minutes | Standalone heartbeat interval (0 = disabled) |
| `TelemetryEnabled` | `bool` | `true` | Attach optional device/application telemetry to supported requests |
| `AppVersion` | `string?` | `null` | Your app version for telemetry |
| `AppBuild` | `string?` | `null` | Your app build number for telemetry |
| `MaxRetries` | `int` | 3 | Retry attempts for failed requests |
| `RetryDelay` | `TimeSpan` | 1 second | Base delay between retries |
| `OfflineFallbackMode` | `OfflineFallbackMode` | `Disabled` | Offline validation mode |
| `MaxOfflineDays` | `int` | 0 | Offline grace period (0 = disabled) |
| `MaxClockSkew` | `TimeSpan` | 5 minutes | Clock tamper tolerance |
| `HttpTimeout` | `TimeSpan` | 30 seconds | Request timeout |
| `Debug` | `bool` | `false` | Enable debug logging |
### Offline Fallback Modes
| Mode | Description |
| ---- | ----------- |
| `Disabled` | Offline fallback disabled. Network failures throw exceptions. |
| `NetworkOnly` | Fall back only after a genuine connection, DNS, timeout, or other transport failure. Every received HTTP response—including 408, 4xx, and 5xx—remains authoritative. **Recommended.** |
| `Always` | Compatibility alias for `NetworkOnly`; it does not override an authoritative HTTP response or a local protocol/security failure. |
## License Lifecycle
### Activation
```csharp
var license = await client.ActivateAsync("LICENSE-KEY");
Console.WriteLine("License activated");
Console.WriteLine($"Status: {license.Status}");
Console.WriteLine($"Plan: {license.PlanKey}");
```
### Validation
```csharp
var result = await client.ValidateAsync("LICENSE-KEY");
if (result.Valid)
{
Console.WriteLine("License is valid!");
Console.WriteLine($"Active Seats: {result.License?.ActiveSeats}/{result.License?.SeatLimit}");
}
else
{
Console.WriteLine($"Invalid: {result.Code} - {result.Message}");
}
```
### Deactivation
```csharp
await client.DeactivateAsync();
```
### Get Status
```csharp
var status = client.GetStatus();
Console.WriteLine($"Status: {status.StatusType}");
```
## Entitlements
### Simple Check
```csharp
if (client.HasEntitlement("premium"))
{
// Unlock premium features
}
```
### Detailed Check
```csharp
var entitlement = client.CheckEntitlement("pro-features");
if (entitlement.Active)
{
EnableProFeatures();
}
else
{
switch (entitlement.Reason)
{
case EntitlementInactiveReason.Expired:
ShowRenewalPrompt();
break;
case EntitlementInactiveReason.NotFound:
ShowUpgradePrompt();
break;
case EntitlementInactiveReason.NoLicense:
ShowActivationPrompt();
break;
}
}
```
## Event Handling
```csharp
// Subscribe to license events
client.Events.On(LicenseSeatEvents.LicenseValidated, _ =>
Console.WriteLine("License validated!"));
client.Events.On(LicenseSeatEvents.ValidationFailed, _ =>
Console.WriteLine("Validation failed!"));
client.Events.On(LicenseSeatEvents.EntitlementChanged, _ =>
Console.WriteLine("Entitlements updated!"));
client.Events.On(LicenseSeatEvents.LicenseActivated, _ =>
Console.WriteLine("License activated"));
client.Events.On(LicenseSeatEvents.LicenseDeactivated, _ =>
Console.WriteLine("License deactivated"));
client.Events.On(LicenseSeatEvents.HeartbeatSuccess, _ =>
Console.WriteLine("Heartbeat sent"));
client.Events.On(LicenseSeatEvents.HeartbeatError, _ =>
Console.WriteLine("Heartbeat failed"));
```
## Offline Validation
```csharp
var client = new LicenseSeatClient(new LicenseSeatClientOptions
{
ApiKey = "pk_live_xxxxxxxx",
ProductSlug = "your-product",
OfflineFallbackMode = OfflineFallbackMode.NetworkOnly,
MaxOfflineDays = 7 // Allow 7 days offline
});
// Validate - falls back to the SDK's cached offline artifact if network fails
var result = await client.ValidateAsync("LICENSE-KEY");
if (result.Offline)
{
Console.WriteLine("Validated offline with cached license");
}
```
> **Offline model note:** the current C# SDK still uses signed offline tokens
> today. Machine files are the newer preferred offline artifact at the API
> level, but this SDK has not migrated yet. Its built-in cache is process
> memory only: offline state does not survive a restart, and this release does
> not claim durable filesystem storage or filesystem tamper resistance.
The SDK automatically fetches and caches Ed25519-signed offline tokens after
an online activation in the same process. When offline:
- Validates token signature cryptographically
- Checks token expiration (`exp` timestamp)
- Detects clock tampering
- Returns cached entitlements
## ASP.NET Core Integration
### Dependency Injection
```csharp
// Program.cs
builder.Services.AddLicenseSeatClient("pk_live_xxxxxxxx", "your-product");
// Or with full options:
builder.Services.AddLicenseSeatClient(options =>
{
options.ApiKey = "pk_live_xxxxxxxx";
options.ProductSlug = "your-product";
options.AutoValidateInterval = TimeSpan.FromMinutes(30);
});
```
### Using in Controllers
```csharp
public class LicenseController : ControllerBase
{
private readonly ILicenseSeatClient _client;
public LicenseController(ILicenseSeatClient client) => _client = client;
[HttpPost("activate")]
public async Task Activate([FromBody] string licenseKey)
{
var license = await _client.ActivateAsync(licenseKey);
return Ok(new { license.Status });
}
[HttpGet("status")]
public IActionResult GetStatus()
{
var status = _client.GetStatus();
return Ok(new { status.StatusType, status.Message });
}
}
```
## Unity Integration
```csharp
using UnityEngine;
using LicenseSeat;
public class LicenseController : MonoBehaviour
{
private LicenseSeatManager _manager;
void Start()
{
_manager = FindObjectOfType();
// Subscribe to events
_manager.Client.Events.On(LicenseSeatEvents.LicenseValidated, _ =>
Debug.Log("License validated!"));
}
public void ActivateLicense(string licenseKey)
{
StartCoroutine(_manager.ActivateCoroutine(licenseKey, (license, error) =>
{
if (error != null)
{
Debug.LogError("License activation failed.");
return;
}
Debug.Log("License activated.");
}));
}
}
```
**Unity SDK Features:**
- **Managed implementation** - No native plugin; pinned managed dependencies are bundled
- **IL2CPP metadata** - A package `link.xml` preserves reflection-sensitive types
- **WebGL transport** - Uses a bounded, same-origin `UnityWebRequest` adapter
- **Editor Tools** - Settings window, inspectors
- **Samples** - Import from Package Manager
These compatibility checks are not proof of a real Unity or IL2CPP build. Test
every Unity LTS version, player platform, and scripting backend you support.
## Godot Integration
```csharp
using Godot;
using LicenseSeat;
public partial class LicenseManager : Node
{
private LicenseSeatClient _client;
public override void _Ready()
{
_client = new LicenseSeatClient(new LicenseSeatClientOptions
{
ApiKey = "pk_live_xxxxxxxx",
ProductSlug = "your-product"
});
}
public async void ValidateLicense(string licenseKey)
{
var result = await _client.ValidateAsync(licenseKey);
if (result.Valid)
GD.Print("License is valid!");
else
GD.Print($"Invalid: {result.Code}");
}
public override void _ExitTree() => _client?.Dispose();
}
```
## Error Handling
```csharp
try
{
var license = await client.ActivateAsync("INVALID-KEY");
}
catch (ApiException ex) when (ex.Code == "license_not_found")
{
Console.WriteLine("License key not found");
}
catch (ApiException ex) when (ex.Code == "seat_limit_exceeded")
{
Console.WriteLine($"All {ex.Details?["seat_limit"]} seats are in use");
}
catch (ApiException ex)
{
Console.WriteLine($"API Error: {ex.Code ?? "unknown"}");
Console.WriteLine($"Status: {ex.StatusCode}");
Console.WriteLine($"Retryable: {ex.IsRetryable}");
}
```
### Common Error Codes
- `license_not_found` - Invalid license key
- `license_expired` - License has expired
- `license_suspended` - License is suspended
- `seat_limit_exceeded` - All seats are in use
- `device_not_activated` - Device not activated for this license
- `invalid_api_key` - Invalid API key
## Test Authentication
Test the configured credential against the protected, side-effect-free `/auth`
endpoint. The response must authenticate with the `licenses:validate` scope:
```csharp
try
{
var authenticated = await client.TestAuthAsync();
Console.WriteLine($"Authenticated: {authenticated}");
}
catch (ApiException ex)
{
Console.WriteLine($"Auth test failed: {ex.Code ?? "unknown"} ({ex.StatusCode})");
}
```
Synchronous version (for Unity or contexts without async):
```csharp
var authenticated = client.TestAuth();
```
Both forms return `bool`; an invalid credential, wrong scope, malformed
response, or transport failure throws instead of returning an ambiguous
partially authenticated object.
## API Reference
### Client Methods
| Method | Description |
| ------ | ----------- |
| `ActivateAsync(licenseKey)` | Activate a license on this device |
| `ValidateAsync(licenseKey)` | Validate a license (check if valid) |
| `DeactivateAsync()` | Deactivate the current license |
| `HasEntitlement(key)` | Check if an entitlement is active |
| `CheckEntitlement(key)` | Get detailed entitlement status |
| `GetStatus()` | Get current license status |
| `GetCurrentLicense()` | Get the cached license |
| `TestAuthAsync()` | Test API key authentication and the required `licenses:validate` scope |
### ValidationResult Properties
| Property | Type | Description |
| -------- | ---- | ----------- |
| `Valid` | `bool` | Whether the license is valid |
| `Code` | `string?` | Error code if invalid |
| `Message` | `string?` | Error message if invalid |
| `Offline` | `bool` | True if validated offline |
| `License` | `License?` | License data |
| `ActiveEntitlements` | `List?` | Active entitlements |
### License Properties
| Property | Type | Description |
| -------- | ---- | ----------- |
| `Key` | `string` | The license key |
| `Status` | `string?` | License status (active, expired, etc.) |
| `ExpiresAt` | `DateTimeOffset?` | When the license expires |
| `PlanKey` | `string?` | Associated plan |
| `SeatLimit` | `int?` | Maximum allowed seats |
| `ActiveSeats` | `int` | Currently used seats |
| `ActiveEntitlements` | `List?` | Active entitlements |
## Telemetry
When `TelemetryEnabled` is `true` (the default), the SDK includes the following
optional telemetry on supported licensing requests:
| Field | Source |
|-------|--------|
| `sdk_name` | Always `csharp` |
| `sdk_version` | `LicenseSeatClient.SdkVersion` |
| `os_name` | `RuntimeInformation.IsOSPlatform` (`Windows`, `macOS`, `Linux`) |
| `os_version` | `Environment.OSVersion.Version` |
| `platform` | `native` (or `unity` if Unity runtime detected) |
| `device_model` | `Environment.MachineName` |
| `device_type` | `desktop`, `server`, or Unity device type |
| `architecture` | `RuntimeInformation.ProcessArchitecture` |
| `cpu_cores` | `Environment.ProcessorCount` |
| `memory_gb` | `GC.GetGCMemoryInfo().TotalAvailableMemoryBytes` (rounded) |
| `locale` | `CultureInfo.CurrentCulture.Name` |
| `language` | `CultureInfo.CurrentUICulture.TwoLetterISOLanguageName` |
| `timezone` | IANA timezone (auto-converted from Windows timezone IDs) |
| `runtime_version` | `RuntimeInformation.FrameworkDescription` (e.g., `.NET 9.0.0`) |
| `app_version` | From config, or `Assembly.GetEntryAssembly` version |
| `app_build` | From config, or `AssemblyInformationalVersion` |
The SDK automatically converts Windows timezone IDs (e.g., `Eastern Standard Time`) to IANA format (e.g., `America/New_York`) for consistency across platforms.
Disable the optional telemetry object with
`LicenseSeatClientOptions.TelemetryEnabled = false`. Core licensing still sends
the license/product context and installation fingerprint, and the server records
successful request activity and the source IP. `device_model` currently uses
`Environment.MachineName`, so treat telemetry and required licensing identifiers
as potentially linked device data rather than anonymous data.
See [[api-reference/telemetry|Telemetry]] for the full field reference.
## Platform Support
| Platform | Package | Install |
| -------- | ------- | ------- |
| **.NET** (Console, ASP.NET, WPF, MAUI) | NuGet | `dotnet add package LicenseSeat --version 0.5.0` |
| **Godot 4** | NuGet | `dotnet add package LicenseSeat --version 0.5.0` |
| **Unity** | UPM | [See Unity section](#unity-integration) |
## Next Steps
- [[sdks/cpp|C++ SDK]] - For native applications
- [[sdks/javascript|JavaScript SDK]] - For web applications
- [[guides/offline-licensing|Offline Licensing]] - Air-gapped validation
- [[api-reference/index|API Reference]] - Direct API access
---
## Interactive SDK Demo
Experience the LicenseSeat [[sdks/javascript|JavaScript SDK]] in action. This live demo simulates a real application with license activation, validation, and entitlement checking.
## What You'll See
- **Simulated App Window** — A mock desktop application with license activation UI
- **SDK Internals** — Real-time view of the SDK's internal state and configuration
- **Event Log** — Live stream of SDK events as they fire
- **Offline Tools** — Test legacy offline-token verification with Ed25519 signatures
## Try It
Enter your publishable API key below to get started. Don't have one? [Sign up](https://licenseseat.com/users/sign_up) to create a free account.
## What's Happening Under the Hood
When you interact with the demo, you're using the actual [[sdks/javascript|JavaScript SDK]]:
1. **Activation** — `sdk.activate('LICENSE-KEY')` registers your license against a device fingerprint
2. **Validation** — `sdk.validateLicense()` checks the license status against the server
3. **Entitlements** — `sdk.hasEntitlement('feature')` gates features based on your license
4. **Events** — The SDK emits events like `activation:success` that you can subscribe to
## Next Steps
- [[sdks/javascript|JavaScript SDK Documentation]] — Full API reference and integration guides
- [[api-reference/index|API Quickstart]] — Get started with the HTTP API
- [[guides/offline-licensing|Offline Licensing]] — Machine-file-first offline model and legacy token compatibility notes
---
## SDKs
LicenseSeat has SDKs for popular platforms to make integration with your app fast and secure.
## Available SDKs
| Platform | Package | Stable version | Platforms and integrations |
| -------- | ------- | -------------- | -------------------------- |
| [[sdks/swift\|Swift]] | `licenseseat-swift` | [0.4.2](https://github.com/licenseseat/licenseseat-swift/releases/tag/v0.4.2) | macOS, iOS, tvOS, watchOS, visionOS, Linux |
| [[sdks/javascript\|JavaScript/TypeScript]] | `@licenseseat/js` | [0.5.0](https://www.npmjs.com/package/@licenseseat/js/v/0.5.0) | Browsers, Node.js 18+, Electron, TypeScript |
| [[sdks/csharp\|C#]] | `LicenseSeat` / `com.licenseseat.sdk` | [0.5.0](https://github.com/licenseseat/licenseseat-csharp/releases/tag/v0.5.0) | .NET Standard 2.0, Godot 4, Unity 2021.3+ package |
| [[sdks/cpp\|C++]] | `licenseseat` | [0.6.0](https://github.com/licenseseat/licenseseat-cpp/releases/tag/v0.6.0) | Native apps, Unreal Engine, VST/AU plugins |
| [[sdks/rust\|Rust]] | `licenseseat` + `tauri-plugin-licenseseat` | [0.6.0](https://github.com/licenseseat/licenseseat-rust/releases/tag/v0.6.0) | Native Rust and Tauri v2 with JS/TS bindings |
## First things first: create a license activation window
When integrating with the LicenseSeat SDK, you should create your own UI / window for users to input the license, so you can design it to match the rest of your app. It should be pretty straightforward: just a text input box and a button you can connect to the SDK actions:

## Then, add the LicenseSeat SDK to your app
Adding the LicenseSeat SDK to your app is very easy and take just a few lines of code.
The SDKs use similar lifecycle concepts. In JavaScript, for example, import the package:
```javascript
import LicenseSeat from '@licenseseat/js';
```
Then just initialize the `LicenseSeat` object with your API Key (you can get it in your LicenseSeat dashboard) and your product slug:
```javascript
const sdk = new LicenseSeat({
apiKey: 'pk_live_xxxxxxxxxxxxxxxxxxxxx',
productSlug: 'my-product'
});
```
And then you can just wire your UI to any of the methods the SDK give you:
```javascript
await sdk.activate('TEST-XXXX-XXXX-XXXX');
```
On top of this, the SDK will do periodic checks in the background to ensure the license is active (so if you revoke it manually via the LicenseSeat dashboard, it actually stops working for the user).
Supported SDKs can attach optional, bounded telemetry according to their
version and configuration, so you can track product usage and environment
analytics in the dashboard:

## SDK features
The current SDKs share these broad capabilities; consult each SDK page for the
exact shipped surface and defaults:
- **License Operations**: Activation, deactivation, online/offline validation
- **Offline Support**: Signed offline artifacts, clock tamper detection, and a gradual transition toward machine-file-first offline validation
- **Entitlements**: Check specific features access each license may give access to, with expiration support
- **Resilience**: Automatic retries, network monitoring, background re-validation
- **Heartbeat**: Standalone 5-minute heartbeat for device liveness detection
- **Telemetry**: Configurable SDK, OS, runtime, device, and app enrichment where supported
- **Events**: Subscribe to license lifecycle events
- **Security**: Credential-in-body routes, strict identity/signature checks, bounded transport/parsing, and fail-closed authorization behavior
Each SDK also includes platform-native integrations (SwiftUI for Swift, React/Vue examples for JS, Unity/Godot for C#, Unreal/JUCE for C++, Tauri v2 for Rust).
> **Offline model note:** the API and newest SDK work converge on **machine files** as the preferred offline artifact because they are encrypted, activation-bound, and fingerprint-bound. Some older SDKs still use signed offline tokens today; their individual docs call that out explicitly instead of pretending every SDK has already migrated.
## Entitlements
Entitlements are feature flags attached to licenses. Use them to:
- Gate premium features
- Implement tiered pricing
- Control feature access per license
```
License: "XXXX-YYYY-ZZZZ"
├── Entitlement: "pro-features" (active)
├── Entitlement: "api-access" (active, expires: 2025-12-31)
└── Entitlement: "updates" (inactive)
```
See [[entitlements|Entitlements]] for setup instructions and best practices.
## Common configuration
All SDKs accept similar configuration options:
| Option | Description | Default |
| ------ | ----------- | ------- |
| `apiKey` | Your publishable API key | **Required** |
| `productSlug` | Your product identifier | **Required** |
| `apiBaseUrl` | API endpoint | `https://licenseseat.com/api/v1` |
| `autoValidateInterval` | Background re-validation interval | 1 hour |
| `heartbeatInterval` | Standalone heartbeat interval | 5 minutes |
| `appVersion` | Your app version (for telemetry) | Auto-detected or `null` |
| `appBuild` | Your app build number (for telemetry) | Auto-detected or `null` |
| `maxRetries` | Retry attempts for failed requests | 3 |
| `maxOfflineDays` | Optional local offline-age policy; the meaning of `0` differs by SDK, so read the SDK-specific table | SDK-specific |
| `deviceId` | Custom device ID | Auto-generated |
| `debug` | Enable debug logging | false |
Option names and defaults vary by language. Read the exact configuration table
for the SDK and version you ship.
## Security Features
Across the released SDK family, security controls include strict signature and
identity validation, bounded transport and parsing, authoritative online
denials, and platform-appropriate persistence where implemented. These details
are not uniform: C# 0.5.0, for example, keeps its offline token cache in process
memory only, while Swift protects persisted Apple-platform state in Keychain.
Use only restricted publishable client credentials, assume embedded credentials
can be extracted, and keep license keys out of URLs and logs. Local client state
is never equivalent to server trust, and no SDK can defend authorization from
an attacker who controls the host process.
## Next Steps
- [[sdks/swift|Swift SDK Documentation]]
- [[sdks/javascript|JavaScript SDK Documentation]]
- [[sdks/csharp|C# SDK Documentation]]
- [[sdks/cpp|C++ SDK Documentation]]
- [[sdks/rust|Rust SDK Documentation]]
- [[api-reference/index|API Quickstart]]
- [[guides/offline-licensing|Offline Licensing Guide]]
---
## JavaScript SDK
Official JavaScript/TypeScript SDK for LicenseSeat. Full TypeScript support with auto-generated type definitions.
> **Current stable release:** [v0.5.0](https://github.com/licenseseat/licenseseat-js/releases/tag/v0.5.0), published as [@licenseseat/js 0.5.0 on npm](https://www.npmjs.com/package/@licenseseat/js/v/0.5.0). Node.js 18 or newer is required.
## Installation
### Package Managers
```bash
# npm
npm install @licenseseat/js@0.5.0
# yarn
yarn add @licenseseat/js@0.5.0
# pnpm
pnpm add @licenseseat/js@0.5.0
```
### CDN (Browser)
```html
```
The CDN examples pin the reviewed release. Keep an exact version in production
instead of allowing a CDN URL to move to a future SDK automatically.
## Security Model
- HTTPS is required. Plain HTTP is accepted only for an explicitly enabled loopback development URL.
- License keys and installation identifiers are sent in JSON request bodies, not URL paths. Bearer credentials are omitted from public health and signing-key requests.
- Browser and renderer-process code cannot keep an API key secret from the end user or injected scripts. Use only least-privilege publishable client credentials; enforce product tenancy, seat limits, license state, and valuable authorization on a trusted server.
- `localStorage` is attacker-writable and is not a trust anchor. Cached online state is revalidated before it grants entitlements. Persistent offline operation requires Ed25519 public keys pinned through trusted application configuration.
- Request and response bodies, JSON structure, identifiers, timestamps, retries, redirects, and deadlines are bounded and validated fail-closed.
No client SDK can protect licensing decisions from an attacker who controls the
application process. Keep high-value authorization on trusted infrastructure.
## Quick Start
### JavaScript
```javascript
import LicenseSeat from '@licenseseat/js';
// Create SDK instance
const sdk = new LicenseSeat({
apiKey: 'pk_live_xxxxxxxx',
productSlug: 'your-product', // Required
debug: true
});
// Activate a license
await sdk.activate('YOUR-LICENSE-KEY');
// Check entitlements (simple boolean)
if (sdk.hasEntitlement('pro')) {
enableProFeatures();
}
// Get current status
const status = sdk.getStatus();
console.log('License status:', status.status);
```
### TypeScript
```typescript
import LicenseSeat, {
type LicenseSeatConfig,
type ValidationResult,
type EntitlementCheckResult,
type LicenseStatus
} from '@licenseseat/js';
const config: LicenseSeatConfig = {
apiKey: 'pk_live_xxxxxxxx',
productSlug: 'your-product',
debug: true
};
const sdk = new LicenseSeat(config);
// Full type inference
const result: ValidationResult = await sdk.validateLicense('LICENSE-KEY');
const status: LicenseStatus = sdk.getStatus();
const hasPro: boolean = sdk.hasEntitlement('pro');
```
TypeScript users get full type support automatically - the package includes generated `.d.ts` declaration files.
## Configuration
```javascript
const sdk = new LicenseSeat({
// Required
productSlug: 'your-product', // Your product slug from dashboard
// Required for authenticated operations
apiKey: 'pk_live_xxxxxxxx',
// API Configuration
apiBaseUrl: 'https://licenseseat.com/api/v1', // Default
requestTimeoutMs: 15000, // Per-attempt request/body deadline
maxResponseBytes: 1048576, // Request/response JSON size ceiling
allowInsecureHttp: false, // HTTP only for explicit loopback development
// Storage
storagePrefix: 'licenseseat_', // localStorage key prefix
// Initialization
autoInitialize: true, // Auto-validate cached license on init
// Auto-Validation
autoValidateInterval: 3600000, // 1 hour (in ms)
// Heartbeat
heartbeatInterval: 300000, // 5 minutes (in ms)
// Telemetry and app info
telemetryEnabled: true, // Attach optional bounded telemetry
browserHighEntropyTelemetryEnabled: false, // Opt in to Chromium UA-CH enrichment
appVersion: null, // Your app version (e.g., '2.1.0')
appBuild: null, // Your app build number (e.g., '42')
// Offline Support
offlineFallbackEnabled: false, // Enable offline validation fallback
maxOfflineDays: 0, // Max days offline (0 = disabled)
offlineLicenseRefreshInterval: 259200000, // 72 hours
maxClockSkewMs: 300000, // 5 minutes
signingPublicKeys: { // Trusted keys for offline use after reload
'key-id-001': 'base64url-encoded-32-byte-public-key'
},
// Network
maxRetries: 3, // Retry attempts for failed requests
retryDelay: 1000, // Initial retry delay (ms)
networkRecheckInterval: 30000, // Check connectivity every 30s when offline
// Debug
debug: false // Enable console logging
});
```
### Configuration Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `productSlug` | `string` | — | **Required.** Your product slug from the dashboard |
| `apiKey` | `string` | `null` | Your publishable API key |
| `apiBaseUrl` | `string` | `https://licenseseat.com/api/v1` | HTTPS API base URL |
| `requestTimeoutMs` | `number` | `15000` | Per-attempt deadline covering headers and the response body |
| `maxResponseBytes` | `number` | `1048576` | Maximum request or response JSON body size |
| `allowInsecureHttp` | `boolean` | `false` | Permit HTTP only for an explicit loopback development URL |
| `storagePrefix` | `string` | `licenseseat_` | Prefix for localStorage keys |
| `autoInitialize` | `boolean` | `true` | Auto-initialize on construction |
| `autoValidateInterval` | `number` | `3600000` | Auto-validation interval (ms) |
| `heartbeatInterval` | `number` | `300000` | Standalone heartbeat interval (ms, 0 = disabled) |
| `telemetryEnabled` | `boolean` | `true` | Attach bounded platform telemetry to supported POST requests |
| `browserHighEntropyTelemetryEnabled` | `boolean` | `false` | Opt in to in-memory Chromium platform-version, architecture, and model hints |
| `appVersion` | `string` | `null` | Your app version for telemetry |
| `appBuild` | `string` | `null` | Your app build number for telemetry |
| `offlineFallbackEnabled` | `boolean` | `false` | Enable offline validation |
| `maxOfflineDays` | `number` | `0` | Max offline days (0 = disabled) |
| `offlineLicenseRefreshInterval` | `number` | `259200000` | Legacy offline-token refresh (72h) |
| `maxClockSkewMs` | `number` | `300000` | Max clock skew (5 min) |
| `signingPublicKeys` | `object` | `{}` | Trusted Ed25519 public keys by key ID for offline use after reload |
| `maxRetries` | `number` | `3` | Max API retry attempts |
| `retryDelay` | `number` | `1000` | Base retry delay (exponential backoff) |
| `networkRecheckInterval` | `number` | `30000` | Network check interval when offline |
| `debug` | `boolean` | `false` | Enable debug logging |
## Core Methods
### Activation
```javascript
// Omit the second argument for default device identification.
const result = await sdk.activate('LICENSE-KEY', {
deviceId: 'custom-device-id', // Optional: auto-generated if not provided
deviceName: "John's MacBook Pro", // Optional: human-readable device name
metadata: { version: '1.0.0' } // Optional: custom metadata
});
console.log('Activation ID:', result.activation?.id);
// {
// license_key: 'LICENSE-KEY',
// device_id: 'web-abc123',
// activated_at: '2024-01-15T10:30:00Z',
// activation: {
// object: 'activation',
// id: 123,
// device_id: 'web-abc123',
// license_key: 'LICENSE-KEY',
// activated_at: '2024-01-15T10:30:00Z',
// license: { ... }
// }
// }
```
### Deactivation
```javascript
const result = await sdk.deactivate();
console.log('Deactivated activation:', result.activation_id);
// {
// object: 'deactivation',
// activation_id: 123,
// deactivated_at: '2024-01-15T12:00:00Z'
// }
```
### Validation
```javascript
const result = await sdk.validateLicense('LICENSE-KEY', {
deviceId: 'device-id' // Optional: required for hardware_locked mode
});
console.log('License valid:', result.valid);
// {
// valid: true,
// license: {
// key: 'LICENSE-KEY',
// status: 'active',
// mode: 'hardware_locked',
// plan_key: 'pro',
// active_seats: 1,
// seat_limit: 3,
// active_entitlements: [
// { key: 'pro', expires_at: null, metadata: null },
// { key: 'beta', expires_at: '2024-12-31T23:59:59Z', metadata: null }
// ],
// product: { slug: 'your-product', name: 'Your Product' }
// },
// active_entitlements: [...]
// }
```
## Entitlements
Entitlements are optional. A license may have zero entitlements if the associated plan has none configured.
### Simple Check (Boolean)
```javascript
if (sdk.hasEntitlement('pro')) {
enableProFeatures();
}
if (sdk.hasEntitlement('beta')) {
showBetaUI();
}
```
### Detailed Check
```javascript
const result = sdk.checkEntitlement('pro');
if (result.active) {
console.log('Entitlement:', result.entitlement);
console.log('Expires:', result.entitlement.expires_at);
} else {
console.log('Reason:', result.reason);
// Possible reasons: 'no_license', 'not_found', 'expired'
}
```
### EntitlementCheckResult Type
| Property | Type | Description |
| -------- | ---- | ----------- |
| `active` | `boolean` | Whether the entitlement is active |
| `reason` | `string?` | Why inactive: `no_license`, `not_found`, `expired` |
| `expires_at` | `string?` | ISO8601 expiration date |
| `entitlement` | `Entitlement?` | Full entitlement object if active |
## Status
### Get Current Status
```javascript
const status = sdk.getStatus();
switch (status.status) {
case 'inactive':
showActivationScreen();
break;
case 'pending':
showLoadingIndicator();
break;
case 'active':
enableFeatures(status.entitlements);
break;
case 'offline-valid':
enableFeatures(status.entitlements);
showOfflineBanner();
break;
case 'invalid':
showErrorScreen(status.message);
break;
case 'offline-invalid':
showRenewalScreen();
break;
}
```
### Status Values
| Status | Description |
| ------ | ----------- |
| `inactive` | No license activated |
| `pending` | License pending validation |
| `active` | License valid (online) |
| `offline-valid` | License valid (offline verification) |
| `invalid` | License invalid |
| `offline-invalid` | License invalid (offline) |
### LicenseStatus Type
| Property | Type | Description |
| -------- | ---- | ----------- |
| `status` | `string` | Status value (see above) |
| `message` | `string?` | Status message |
| `license` | `string?` | License key (if active) |
| `device` | `string?` | Device fingerprint (if active) |
| `activated_at` | `string?` | ISO8601 activation timestamp |
| `last_validated` | `string?` | ISO8601 last validation timestamp |
| `entitlements` | `Entitlement[]?` | Active entitlements |
## Events
Subscribe to SDK lifecycle events for reactive UIs.
```javascript
// Subscribe
const unsubscribe = sdk.on('activation:success', () => {
console.log('License activated');
});
// Unsubscribe
unsubscribe();
// or
sdk.off('activation:success', handler);
```
### Available Events
| Event | Data | Description |
| ----- | ---- | ----------- |
| **Lifecycle** | | |
| `license:loaded` | `CachedLicense` | Cached license loaded on init |
| `sdk:reset` | — | SDK was reset |
| `sdk:destroyed` | — | SDK was destroyed |
| `sdk:error` | `{ message, error? }` | General SDK error |
| **Activation** | | |
| `activation:start` | `{ licenseKey, deviceId }` | Activation started |
| `activation:success` | `CachedLicense` | Activation succeeded |
| `activation:error` | `{ licenseKey, error }` | Activation failed |
| **Deactivation** | | |
| `deactivation:start` | `CachedLicense` | Deactivation started |
| `deactivation:success` | `DeactivationResponse` | Deactivation succeeded |
| `deactivation:error` | `{ error, license }` | Deactivation failed |
| **Validation** | | |
| `validation:start` | `{ licenseKey }` | Validation started |
| `validation:success` | `ValidationResult` | Online validation succeeded |
| `validation:failed` | `ValidationResult` | Validation failed (invalid) |
| `validation:error` | `{ licenseKey, error }` | Validation error (network) |
| `validation:offline-success` | `ValidationResult` | Offline validation succeeded |
| `validation:offline-failed` | `ValidationResult` | Offline validation failed |
| `validation:auth-failed` | `{ licenseKey, error, cached }` | Auth failed during validation |
| **Heartbeat** | | |
| `heartbeat:success` | `{ received_at }` | Heartbeat acknowledged by server |
| `heartbeat:error` | `{ error }` | Heartbeat request failed |
| **Auto-Validation** | | |
| `autovalidation:cycle` | `{ nextRunAt: Date }` | Auto-validation scheduled |
| `autovalidation:stopped` | — | Auto-validation stopped |
| **Network** | | |
| `network:online` | — | Network connectivity restored |
| `network:offline` | `{ error }` | Network connectivity lost |
| **Offline Token** | | |
| `offlineToken:fetching` | `{ licenseKey }` | Fetching legacy offline token |
| `offlineToken:fetched` | `{ licenseKey, data }` | Legacy offline token fetched |
| `offlineToken:fetchError` | `{ licenseKey, error }` | Fetch failed |
| `offlineToken:ready` | `{ kid, exp_at }` | Offline assets synced |
| `offlineToken:verified` | `{ payload }` | Signature verified |
| `offlineToken:verificationFailed` | `{ payload }` | Signature invalid |
## Singleton Pattern
For applications that need a shared SDK instance across modules:
```javascript
import { configure, getSharedInstance, resetSharedInstance } from '@licenseseat/js';
// Configure once at app startup
configure({
apiKey: 'pk_live_xxxxxxxx',
productSlug: 'your-product'
});
// Use anywhere in your app
const sdk = getSharedInstance();
await sdk.activate('LICENSE-KEY');
// Reset if needed (clears all state)
resetSharedInstance();
```
## Lazy Initialization
By default, the SDK initializes immediately and validates any cached license. To disable this:
```javascript
const sdk = new LicenseSeat({
apiKey: 'pk_live_xxxxxxxx',
productSlug: 'your-product',
autoInitialize: false // Don't auto-initialize
});
// Later, when ready:
sdk.initialize();
```
This is useful when you need to:
- Delay network requests until user interaction
- Set up event listeners before initialization
- Control exactly when validation occurs
## Offline Support
The SDK supports offline license validation using Ed25519 cryptographic signatures.
> **Note:** The current JavaScript SDK offline flow still uses signed offline tokens. Machine files are the newer preferred offline artifact at the API level, but the JavaScript SDK has not migrated to them yet. Treat offline tokens as the current JS implementation detail, not the long-term product direction.
### Enable Offline Fallback
```javascript
const sdk = new LicenseSeat({
apiKey: 'pk_live_xxxxxxxx',
productSlug: 'your-product',
offlineFallbackEnabled: true, // Enable offline fallback
maxOfflineDays: 7, // Allow at most 7 days offline
signingPublicKeys: {
'key-id-001': 'base64url-encoded-32-byte-public-key'
}
});
```
### How It Works
1. **Online**: License validated against server
2. **Activation**: Legacy offline token + public key established for the current process
3. **Offline**: Cached token verified cryptographically (Ed25519)
4. **Clock Tamper Detection**: Prevents users from rolling back system clock
Automatic fallback is limited to transport failures and HTTP 408. Other HTTP
responses, malformed responses, identity mismatches, and local cryptographic
or protocol failures remain authoritative and cannot be replaced by cached
authorization.
For offline authority that survives an application reload, embed or inject the
expected public key with `signingPublicKeys`. A key recovered from
`localStorage` is deliberately ignored because attacker-writable storage cannot
establish trust. A key fetched over authenticated HTTPS can verify artifacts in
the current process, but it is not a durable trust anchor.
### Manual Offline Methods
```javascript
// Sync offline assets (downloads the legacy token + signing key, caches them)
await sdk.syncOfflineAssets();
// Verify cached offline token (use when offline)
const result = await sdk.verifyCachedOffline();
// { valid: true, offline: true, license: {...}, activation: {...} }
// Get offline token from server
const token = await sdk.getOfflineToken();
// Get signing key
const signingKey = await sdk.getSigningKey('key-id-001');
// Verify a specific token manually
const isValid = await sdk.verifyOfflineToken(token, signingKey.public_key);
```
### Offline Token Structure
```javascript
{
object: 'offline_token',
token: {
schema_version: 1,
license_key: 'LICENSE-KEY',
product_slug: 'your-product',
plan_key: 'pro',
mode: 'hardware_locked',
device_id: 'web-abc123',
iat: 1704067200, // Issued at (Unix timestamp)
exp: 1706659200, // Expires at (Unix timestamp)
nbf: 1704067200, // Not before (Unix timestamp)
license_expires_at: null,
kid: 'key-id-001',
entitlements: [
{ key: 'pro', expires_at: null }
],
metadata: {}
},
signature: {
algorithm: 'Ed25519',
key_id: 'key-id-001',
value: 'base64url-encoded-signature'
},
canonical: '{"entitlements":[...],"exp":...}'
}
```
### Offline Validation Result
When offline, `validateLicense()` returns with `offline: true`:
```javascript
const result = await sdk.validateLicense('LICENSE-KEY');
if (result.offline) {
console.log('Validated offline');
}
```
## Error Handling
The SDK exports custom error classes for precise error handling:
```javascript
import LicenseSeat, {
APIError,
LicenseError,
ConfigurationError,
CryptoError
} from '@licenseseat/js';
try {
await sdk.activate('INVALID-KEY');
} catch (error) {
if (error instanceof APIError) {
console.log('HTTP Status:', error.status);
console.log('Error Code:', error.data?.error?.code);
} else if (error instanceof LicenseError) {
console.log('License error:', error.code);
} else if (error instanceof CryptoError) {
console.log('Crypto error:', error.message);
}
}
```
### Error Types
| Error | Properties | Description |
| ----- | ---------- | ----------- |
| `APIError` | `status`, `data` | HTTP request failures |
| `LicenseError` | `code` | License operation failures |
| `ConfigurationError` | — | SDK misconfiguration |
| `CryptoError` | — | Cryptographic operation failures |
### API Error Format
```javascript
{
error: {
code: 'license_not_found', // Machine-readable error code
message: 'License not found.', // Human-readable message
details: { ... } // Optional additional details
}
}
```
### Common Error Codes
- `unauthorized` - Invalid or missing API key
- `license_not_found` - License key doesn't exist
- `license_expired` - License has expired
- `license_suspended` - License is suspended
- `license_revoked` - License has been revoked
- `seat_limit_reached` - No more seats available
- `device_already_activated` - Device is already activated
- `activation_not_found` - Activation doesn't exist (for deactivation)
## Utility Methods
### Test Authentication
```javascript
try {
const result = await sdk.testAuth();
console.log('Authenticated:', result.authenticated); // Always true if succeeds
console.log('Healthy:', result.healthy); // API health status
console.log('API Version:', result.api_version); // e.g., '1.0.0'
} catch (error) {
console.error('Auth failed:', error?.status ?? 'client_error');
}
```
> **Note:** `testAuth()` calls the protected, side-effect-free `/auth` endpoint.
> Success proves that the configured credential authenticated with the required
> `licenses:validate` scope; the public health endpoint is not used as proof of
> credential validity.
### Reset SDK
Clears all cached data and stops timers:
```javascript
sdk.reset();
```
### Destroy SDK
Fully destroys the instance and releases all resources:
```javascript
sdk.destroy();
// Do not use sdk after this
```
## TypeScript Types
All types are exported from the package:
```typescript
import type {
LicenseSeatConfig,
ActivationOptions,
ValidationOptions,
ValidationResult,
EntitlementCheckResult,
LicenseStatus,
Entitlement,
CachedLicense,
ActivationResponse,
DeactivationResponse,
OfflineToken
} from '@licenseseat/js';
```
## React Integration
```jsx
import { useState, useEffect, createContext, useContext } from 'react';
import LicenseSeat from '@licenseseat/js';
// Context
const LicenseContext = createContext(null);
// Provider
export function LicenseProvider({ children, config }) {
const [sdk] = useState(() => new LicenseSeat(config));
const [status, setStatus] = useState(sdk.getStatus());
useEffect(() => {
const events = [
'activation:success',
'deactivation:success',
'validation:success',
'validation:failed',
'validation:offline-success',
'validation:offline-failed'
];
const unsubscribers = events.map(event =>
sdk.on(event, () => setStatus(sdk.getStatus()))
);
return () => {
unsubscribers.forEach(unsub => unsub());
sdk.destroy(); // Clean up on unmount
};
}, [sdk]);
return (
{children}
);
}
// Hook
export function useLicense() {
return useContext(LicenseContext);
}
// Usage
function App() {
return (
);
}
function MainApp() {
const { sdk, status } = useLicense();
if (status.status === 'active') {
return ;
}
return ;
}
```
## Browser Usage (No Build Tools)
```html
LicenseSeat Demo
```
## Node.js Usage
The SDK is designed for browsers but works in Node.js with polyfills:
```javascript
// Required polyfills for Node.js
const storage = {};
globalThis.localStorage = {
getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; },
setItem(key, value) { storage[key] = String(value); },
removeItem(key) { delete storage[key]; },
clear() { for (const key in storage) delete storage[key]; },
};
const originalKeys = Object.keys;
Object.keys = function(obj) {
if (obj === globalThis.localStorage) return originalKeys(storage);
return originalKeys(obj);
};
globalThis.document = { createElement: () => ({ getContext: () => null }), querySelector: () => null };
globalThis.window = { navigator: {}, screen: {} };
globalThis.navigator = { userAgent: 'Node.js', language: 'en', hardwareConcurrency: 4 };
// Now import the SDK
const { default: LicenseSeat } = await import('@licenseseat/js');
```
> **Note:** In Node.js, device fingerprinting uses fallback values. For consistent device identification, pass an explicit `deviceId` to `activate()`.
## Telemetry
When `telemetryEnabled` is `true` (the default), the SDK includes the following
optional telemetry on supported POST requests:
| Field | Browser | Node.js | Electron |
|-------|---------|---------|----------|
| `sdk_name` | `js` | `js` | `js` |
| `sdk_version` | Yes | Yes | Yes |
| `os_name` | Yes | Yes | Yes |
| `os_version` | Yes | Yes | Yes |
| `platform` | `browser` | `node` | `electron` |
| `device_model` | Via userAgentData | -- | -- |
| `device_type` | `desktop`/`phone`/`tablet` | `server` | `desktop` |
| `architecture` | Via userAgentData | `process.arch` | `process.arch` |
| `cpu_cores` | `navigator.hardwareConcurrency` | `os.cpus().length` | Yes |
| `memory_gb` | `navigator.deviceMemory` | `os.totalmem()` | Yes |
| `locale` | `navigator.language` | `process.env.LANG` | Yes |
| `language` | 2-letter code from locale | 2-letter code from locale | Yes |
| `timezone` | `Intl.DateTimeFormat` | `Intl.DateTimeFormat` | Yes |
| `screen_resolution` | `screen.width`x`screen.height` | -- | Yes |
| `display_scale` | `window.devicePixelRatio` | -- | Yes |
| `browser_name` | Chrome, Safari, Firefox, Edge | -- | -- |
| `browser_version` | Detected from UA/brands | -- | -- |
| `runtime_version` | -- | `process.versions.node` | `process.versions.electron` |
| `app_version` | From config | From config | From config |
| `app_build` | From config | From config | From config |
Browsers expose reduced values by default. Set
`browserHighEntropyTelemetryEnabled: true` to request bounded Chromium
`platformVersion`, `architecture`, and `model` hints. The request never delays
a licensing call, successful values remain in this SDK instance's memory, and
disabling `telemetryEnabled` prevents both the high-entropy request and
telemetry attachment.
Set `telemetryEnabled: false` to omit the optional telemetry object. The license
key, product context, device/installation identifier, successful request
activity, and server-visible source IP remain part of the licensing service.
Browser/runtime attributes and persistent fingerprints can be linked device
data; disabling telemetry alone does not establish GDPR or marketplace-policy
compliance.
See [[api-reference/telemetry|Telemetry]] for the full field reference.
## Platform Support
| Platform | Version | Notes |
| -------- | ------- | ----- |
| Chrome | 80+ | Full support |
| Firefox | 75+ | Full support |
| Safari | 14+ | Full support |
| Edge | 80+ | Full support |
| Node.js | 18+ | Requires polyfills |
| Electron | Latest | Full support |
## Next Steps
- [[sdks/swift|Swift SDK]] - For Apple platforms
- [[guides/offline-licensing|Offline Licensing]] - Air-gapped validation
- [[api-reference/index|API Reference]] - Direct API access
---
## Rust SDK
Official Rust SDK and Tauri plugin for LicenseSeat. Use the core `licenseseat` crate in native Rust applications, or pair `tauri-plugin-licenseseat` with `@licenseseat/tauri-plugin` in Tauri v2 apps.
> **Current stable release:** [v0.6.0](https://github.com/licenseseat/licenseseat-rust/releases/tag/v0.6.0). The matching core crate, Tauri crate, and frontend bindings are all version 0.6.0.
## Packages
This SDK family ships as three published packages:
| Package | Description | Links |
| ------- | ----------- | ----- |
| `licenseseat` | Core Rust SDK for native Rust apps | [crates.io 0.6.0](https://crates.io/crates/licenseseat/0.6.0) |
| `tauri-plugin-licenseseat` | Rust-side Tauri v2 plugin | [crates.io 0.6.0](https://crates.io/crates/tauri-plugin-licenseseat/0.6.0) |
| `@licenseseat/tauri-plugin` | TypeScript/JavaScript bindings for the Tauri plugin | [npm 0.6.0](https://www.npmjs.com/package/@licenseseat/tauri-plugin/v/0.6.0) |
## Features
- Full license lifecycle: activate, validate, deactivate, heartbeat
- Machine-file-first offline validation with Ed25519 signatures and AES-256-GCM payload encryption
- Durable random installation identity by default, with opt-in C++-compatible hardware fingerprint components
- Entitlement checks and cached validation state
- Background re-validation, heartbeat, and network recheck loops
- Restore cached license state on app startup
- Release discovery and signed download-token APIs
- Tauri v2 plugin with typed JS/TS bindings, state snapshots, and event subscriptions
- Manual offline helpers for legacy offline tokens and machine files
## Installation
### Pure Rust
```bash
cargo add licenseseat@0.6.0
```
Offline validation support is included in the default feature set. If you disable default features and still want offline verification, add it back explicitly:
```bash
cargo add licenseseat@0.6.0 --no-default-features --features "native-tls,offline"
```
### Tauri v2
Install the Rust plugin in `src-tauri/`:
```bash
cd src-tauri
cargo add tauri-plugin-licenseseat@0.6.0
```
Install the frontend bindings in your app:
```bash
npm add @licenseseat/tauri-plugin@0.6.0
```
For applications, review and commit `Cargo.lock` and the frontend package-manager
lockfile so the complete dependency graph—not only these direct requirements—is
reproducible in production.
## Quick Start
### Pure Rust
```rust
use licenseseat::{Config, LicenseSeat};
#[tokio::main]
async fn main() -> licenseseat::Result<()> {
let sdk = LicenseSeat::try_new(Config::new("pk_live_xxxxxxxx", "your-product"))?;
sdk.activate("USER-LICENSE-KEY").await?;
println!("License activated");
let validation = sdk.validate().await?;
if validation.valid {
println!("Plan: {}", validation.license.plan_key);
}
if sdk.has_entitlement("pro-features") {
enable_pro_features();
}
Ok(())
}
```
### Tauri v2
Register the plugin:
```rust
// src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_licenseseat::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
Configure it in `tauri.conf.json`:
```json
{
"plugins": {
"licenseseat": {
"apiKey": "pk_live_xxxxxxxx",
"productSlug": "your-product"
}
}
}
```
Add permissions:
```json
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"licenseseat:default"
]
}
```
Use it in the frontend:
```typescript
import {
activate,
bootstrapState,
hasAnyEntitlement,
subscribeState,
} from '@licenseseat/tauri-plugin';
const state = await bootstrapState();
if (!state.license) {
await activate('USER-LICENSE-KEY');
}
if (await hasAnyEntitlement(['pro-features', 'cloud-sync'])) {
enableProFeatures();
}
const unlisten = await subscribeState(({ state: nextState }) => {
console.log('Client status:', nextState.clientStatus);
}, { emitCurrent: true });
```
## Configuration
### Rust Configuration
```rust
use licenseseat::{Config, OfflineFallbackMode};
use std::time::Duration;
let config = Config {
api_key: "pk_live_xxxxxxxx".into(),
product_slug: "your-product".into(),
api_base_url: "https://licenseseat.com/api/v1".into(),
storage_prefix: "licenseseat_".into(),
storage_path: None,
device_identifier: None,
signing_public_key: None,
signing_key_id: None,
auto_validate_interval: Duration::from_secs(3600),
heartbeat_interval: Duration::from_secs(300),
network_recheck_interval: Duration::from_secs(30),
request_timeout: Duration::from_secs(30),
verify_ssl: true,
max_retries: 3,
retry_delay: Duration::from_secs(1),
offline_fallback_mode: OfflineFallbackMode::NetworkOnly,
offline_token_refresh_interval: Duration::from_secs(259_200),
enable_legacy_offline_tokens: false,
max_offline_days: 0,
max_clock_skew: Duration::from_secs(300),
telemetry_enabled: true,
app_version: Some("1.0.0".into()),
app_build: Some("100".into()),
debug: false,
..Default::default()
};
```
### Tauri Plugin Configuration
```json
{
"plugins": {
"licenseseat": {
"apiKey": "pk_live_xxxxxxxx",
"productSlug": "your-product",
"apiBaseUrl": "https://licenseseat.com/api/v1",
"storagePrefix": "licenseseat_",
"storagePath": null,
"deviceIdentifier": null,
"signingPublicKey": "BASE64_ED25519_PUBLIC_KEY",
"signingKeyId": "production-key-v1",
"autoValidateInterval": 3600,
"heartbeatInterval": 300,
"networkRecheckInterval": 30,
"timeoutSeconds": 30,
"verifySsl": true,
"offlineFallbackMode": "networkOnly",
"offlineTokenRefreshInterval": 259200,
"enableLegacyOfflineTokens": false,
"maxOfflineDays": 7,
"telemetryEnabled": true,
"appVersion": "1.0.0",
"appBuild": "100",
"debug": false
}
}
}
```
### Key Options
| Rust | Tauri | Description |
| ---- | ----- | ----------- |
| `api_key` | `apiKey` | Publishable API key |
| `product_slug` | `productSlug` | Product slug |
| `api_base_url` | `apiBaseUrl` | API base URL |
| `storage_prefix` | `storagePrefix` | Cache namespace prefix |
| `storage_path` | `storagePath` | Optional persisted cache directory |
| `device_identifier` | `deviceIdentifier` | Override the default durable random installation identity |
| `send_fingerprint_components` | `sendFingerprintComponents` | Opt in to raw hardware fingerprint component collection (default `false`) |
| `signing_public_key` | `signingPublicKey` | Optional pinned Ed25519 key for offline verification |
| `signing_key_id` | `signingKeyId` | Key ID associated with `signing_public_key` |
| `auto_validate_interval` | `autoValidateInterval` | Background validation interval |
| `heartbeat_interval` | `heartbeatInterval` | Background heartbeat interval |
| `network_recheck_interval` | `networkRecheckInterval` | Connectivity recheck interval while offline |
| `request_timeout` | `timeoutSeconds` | HTTP request timeout |
| `max_retries` | `maxRetries` | Maximum retry attempts for retryable requests (default `3`) |
| `retry_delay_seconds` | `retryDelaySeconds` | Initial retry delay in seconds; exponential backoff is capped by the SDK (default `1`) |
| `verify_ssl` | `verifySsl` | TLS certificate verification |
| `offline_fallback_mode` | `offlineFallbackMode` | Offline fallback strategy |
| `offline_token_refresh_interval` | `offlineTokenRefreshInterval` | Periodic refresh interval for cached offline artifacts. Zero disables periodic refresh, not the one-shot sync after activation. |
| `enable_legacy_offline_tokens` | `enableLegacyOfflineTokens` | Allow legacy offline-token fallback |
| `max_clock_skew_seconds` | `maxClockSkewSeconds` | Maximum accepted clock skew for signed offline artifacts, in seconds (default `300`) |
| `max_offline_days` | `maxOfflineDays` | Optional host-side signed-artifact age cap. `0` means no additional cap; signed expiry is still enforced. |
| `telemetry_enabled` | `telemetryEnabled` | Telemetry toggle |
| `app_version` | `appVersion` | App version for telemetry |
| `app_build` | `appBuild` | App build for telemetry |
| `debug` | `debug` | Debug logging |
| `emit_frontend_events` | `emitFrontendEvents` | Emit SDK events to the webview (default `true`). Set `false` when the app exposes a narrow native licensing facade; native Rust subscribers still receive events |
## Offline Validation
The Rust SDK now follows the same machine-file-first offline flow as the reference C++ SDK.
### Fallback Modes
| Rust | Tauri | Behavior |
| ---- | ----- | -------- |
| `OfflineFallbackMode::NetworkOnly` | omit the option or use `networkOnly` | Default. May consult a signed offline artifact after a transport failure, timeout, HTTP 408, or 5xx response. |
| `OfflineFallbackMode::Always` | `always` | Includes every `NetworkOnly` case and HTTP 429 rate limiting—nothing else. |
Neither mode overrides authentication or configuration failures, ordinary
client/business errors, malformed or identity-substituted responses, or
superseded local operations. `networkOnly` is the canonical default;
`network_only` is also accepted. The legacy aliases `allow_offline`,
`allowOffline`, `offline_first`, and `offlineFirst` remain accepted only for
compatibility and map to `always`.
Production offline startup requires both a pinned `signing_public_key` /
`signingPublicKey` and its matching `signing_key_id` / `signingKeyId`. A key
fetched online may establish trust for the current process; its persisted copy
is diagnostic cache and is deliberately not a trust anchor after restart.
### How It Works
1. Activation binds the license to a canonical fingerprint.
2. The SDK starts one background machine-file checkout after activation.
3. The machine file is Ed25519-signed and AES-256-GCM encrypted.
4. When offline, the SDK verifies the signature, decrypts the payload, checks the fingerprint binding, and enforces expiry/grace rules locally.
5. Legacy offline tokens remain available only as an explicit compatibility fallback via `enable_legacy_offline_tokens`.
Activation already schedules the initial offline-asset sync. Do not normally
call `sync_offline_assets()` / `syncOfflineAssets()` immediately afterward:
that starts a second, serialized checkout. Use the manual method later when an
explicit refresh or retry is intentional. Applications that need to confirm
initial readiness should subscribe to the machine-file/offline-asset events
before activation.
### Manual Offline Helpers
Pure Rust:
```rust
let machine_file = sdk.checkout_machine_file("USER-LICENSE-KEY", None, Some(30)).await?;
let verification = sdk.verify_machine_file(&machine_file, None, None, None)?;
if verification.valid {
println!("Offline machine file verified");
}
```
Tauri frontend:
```typescript
import {
checkoutMachineFile,
verifyMachineFile,
fetchSigningKey,
} from '@licenseseat/tauri-plugin';
const machineFile = await checkoutMachineFile('USER-LICENSE-KEY');
await fetchSigningKey('offline-key-1');
const result = await verifyMachineFile(machineFile);
if (result.valid) {
enableOfflineMode();
}
```
Legacy offline-token helpers remain available for manual workflows:
- Rust: `generate_offline_token()`, `verify_offline_token()`
- Tauri: `generateOfflineToken()`, `verifyOfflineToken()`
For a later intentional refresh or retry while the app is online, the Tauri
plugin exposes `syncOfflineAssets()`. The initial post-activation sync is
automatic, so an immediate manual call is redundant.
## License Lifecycle
### Rust
```rust
let license = sdk.activate("USER-LICENSE-KEY").await?;
let validation = sdk.validate().await?;
if validation.valid {
println!("License valid");
}
let heartbeat = sdk.heartbeat().await?;
println!("Heartbeat acknowledged at {}", heartbeat.received_at);
sdk.deactivate().await?;
```
### Tauri
```typescript
import {
activate,
validate,
heartbeat,
deactivate,
getClientStatus,
} from '@licenseseat/tauri-plugin';
await activate('USER-LICENSE-KEY');
const validation = await validate();
if (validation.valid) {
console.log('License valid');
}
await heartbeat();
console.log(await getClientStatus()); // active, offline_valid, inactive, invalid, pending, offline_invalid
await deactivate();
```
## Recommended Tauri Integration
For most Tauri apps, treat the plugin as a stateful client rather than wiring every lifecycle event manually:
- Use `bootstrapState()` on startup to restore cached state and optionally run one validation pass.
- Use `activateAndGetState()` after a successful activation flow when your UI needs the refreshed state immediately.
- Use `getState()` for one-off reads.
- Use `subscribeState()` for UI updates driven by lifecycle changes.
- Use `normalizeError()` before rendering frontend-facing errors.
```typescript
import {
activateAndGetState,
bootstrapState,
normalizeError,
subscribeState,
} from '@licenseseat/tauri-plugin';
const initialState = await bootstrapState();
const unlisten = await subscribeState(({ state }) => {
renderLicenseState(state);
}, { emitCurrent: true });
try {
const nextState = await activateAndGetState('USER-LICENSE-KEY');
renderLicenseState(nextState);
} catch (error) {
const licenseError = normalizeError(error);
showError(licenseError.message);
}
```
## Restore, Releases, and Distribution
The Rust and Tauri SDKs now expose the same restore and release-management APIs as the C++ SDK.
### Restore Cached State
Rust:
```rust
let restored = sdk.restore_license().await;
if restored.restored {
println!("Restored cached session");
}
```
Tauri:
```typescript
import { restoreLicense } from '@licenseseat/tauri-plugin';
const restored = await restoreLicense();
if (restored.restored) {
console.log(restored.status.status);
}
```
### Releases and Download Tokens
Rust:
```rust
let release = sdk.get_latest_release(None, None, None).await?;
let token = sdk
.generate_download_token(&release.version, "USER-LICENSE-KEY", None, None)
.await?;
```
Tauri:
```typescript
import { getLatestRelease, generateDownloadToken } from '@licenseseat/tauri-plugin';
const release = await getLatestRelease();
const token = await generateDownloadToken(release.version, 'USER-LICENSE-KEY');
```
## Tauri API Surface
### Core Commands
| Function | Description |
| -------- | ----------- |
| `activate(key, options?)` | Activate a license key |
| `validateKey(key)` | Validate an explicit license key |
| `validate()` | Validate the current cached license |
| `deactivate()` | Deactivate the current license |
| `deactivateKey(key, fingerprint?)` | Deactivate an explicit license/fingerprint pair |
| `heartbeat()` | Send a heartbeat for the current license |
| `heartbeatKey(key, fingerprint?)` | Heartbeat an explicit license/fingerprint pair |
| `getStatus()` | Get the structured status object |
| `getClientStatus()` | Get the stable status string |
| `isOnline()` | Check the SDK's current online/offline view |
| `getFingerprint()` | Read the current fingerprint |
| `restoreLicense()` | Restore cached license state |
| `health()` | Check API reachability |
| `reset()` | Clear cached SDK state |
### State, Events, and UI Helpers
| Function | Description |
| -------- | ----------- |
| `getState()` | Get a consolidated SDK state snapshot |
| `getAdminSnapshot()` | Get a detailed admin/debug snapshot |
| `restoreAndGetState()` | Restore a cached session and return the refreshed state |
| `activateAndGetState(key, options?)` | Activate, attempt validation, and return the latest state |
| `bootstrapState(options?)` | Restore, optionally validate, and return the latest state |
| `listenEvent(name, handler)` | Subscribe to a specific stable plugin event |
| `subscribeState(listener, options?)` | Subscribe to state-changing lifecycle events |
| `normalizeError(error)` | Normalize invoke/plugin errors into `LicenseSeatPluginError` |
### Entitlements and Cached State
| Function | Description |
| -------- | ----------- |
| `checkEntitlement(key)` | Get detailed entitlement status |
| `hasEntitlement(key)` | Boolean entitlement check |
| `hasAnyEntitlement(keys)` | True when any provided entitlement is active |
| `hasAllEntitlements(keys)` | True when all provided entitlements are active |
| `getEntitlements()` | List active entitlements from cached validation |
| `getLicense()` | Get the cached license, if any |
| `getActiveEntitlementKeys()` | List active entitlement keys from the current state snapshot |
| `getPlanKey()` | Get the current plan key from the state snapshot |
| `getLicenseMode()` | Get the current license mode from the state snapshot |
### Releases and Distribution
| Function | Description |
| -------- | ----------- |
| `getLatestRelease(productSlug?, channel?, platform?)` | Fetch the latest published release |
| `listReleases(productSlug?, options?)` | List releases with pagination metadata |
| `generateDownloadToken(version, licenseKey, productSlug?, platform?)` | Create a signed release download token |
### Offline Helpers
| Function | Description |
| -------- | ----------- |
| `generateOfflineToken(key, fingerprint?, ttlDays?)` | Generate a legacy offline token |
| `verifyOfflineToken(token, publicKeyB64?)` | Verify a legacy offline token locally |
| `checkoutMachineFile(key, options?)` | Checkout a machine file |
| `fetchSigningKey(keyId)` | Fetch and cache an Ed25519 public key |
| `syncOfflineAssets()` | Refresh cached machine-file/signing-key/token artifacts |
| `verifyMachineFile(file, options?)` | Verify and decrypt a machine file locally |
## Entitlements
Rust:
```rust
if sdk.has_entitlement("cloud-sync") {
enable_cloud_sync();
}
let status = sdk.check_entitlement("pro-features");
if status.active {
enable_pro_features();
}
```
Tauri:
```typescript
import { hasEntitlement, checkEntitlement, getEntitlements } from '@licenseseat/tauri-plugin';
if (await hasEntitlement('cloud-sync')) {
enableCloudSync();
}
const status = await checkEntitlement('pro-features');
const entitlements = await getEntitlements();
```
## Events
Both the Rust crate and the Tauri plugin expose license lifecycle events.
### Rust
```rust
let mut events = sdk.subscribe();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
println!("event = {}", event.kind);
}
});
```
### Tauri
```typescript
import {
LICENSESEAT_EVENTS,
listenEvent,
subscribeState,
} from '@licenseseat/tauri-plugin';
await listenEvent(LICENSESEAT_EVENTS.VALIDATION_SUCCESS, () => {
console.log('License validation succeeded');
});
const unlisten = await subscribeState(({ state, eventName }) => {
console.log('State changed via', eventName, state.clientStatus);
}, { emitCurrent: true });
```
Common event families:
- Activation, validation, deactivation, and heartbeat success/error events
- Online/offline network state events
- Offline validation success/failure events
- Machine-file fetch, ready, verified, and verification-failed events
- Legacy offline-token events for compatibility flows
## Status Values
The stable client-status values are:
- `active`
- `offline_valid`
- `offline_invalid`
- `inactive`
- `invalid`
- `pending`
Tauri's `getClientStatus()` and `getStatus()` use these snake_case values.
## Tauri Permissions
The plugin integrates with Tauri's permissions system. `licenseseat:default`
is intentionally limited to ordinary lifecycle, status, and entitlement
operations; it does not grant every diagnostic, offline-management, release,
or destructive command.
| Permission set | Additional surface |
| -------------- | ------------------ |
| `licenseseat:diagnostics` | Detailed admin snapshots, including cached-artifact and local-path diagnostics |
| `licenseseat:advanced-lifecycle` | Explicit key/fingerprint operations and destructive reset |
| `licenseseat:offline-management` | Raw artifact checkout/verification, signing-key lookup, and refresh |
| `licenseseat:releases` | Release lookup and license-bound download-token generation |
For fine-grained access, use the generated per-command permission identifiers, for example:
- `licenseseat:allow-get-state`
- `licenseseat:allow-get-admin-snapshot`
- `licenseseat:allow-sync-offline-assets`
- `licenseseat:allow-verify-machine-file`
These command-specific identifiers are generated from the plugin command surface and shipped with the crate under `permissions/autogenerated/commands/`.
Do not load untrusted remote content into a Tauri window that has LicenseSeat
permissions. The generic renderer API necessarily handles customer-entered
license keys and returns licensing state. Higher-assurance applications should
keep those permissions out of the renderer, manage the core SDK in Rust, and
expose a narrow app-specific command facade with redacted state and native
entitlement gates.
## Platform Requirements
| Platform | Requirement |
| -------- | ----------- |
| Rust | 1.85+ for the core crate; 1.88+ for the Tauri plugin |
| Tauri | v2.0.0+ |
| Node.js | 18+ for the JS bindings |
| TLS | `rustls` by default, `native-tls` optional in the core crate |
## Security Notes
- Use only a restricted `pk_*` publishable key in client applications. Never ship an `sk_*`, administrator, or server-write credential, and assume every embedded client key can be extracted.
- Keep license keys out of URLs, logs, crash reports, analytics, renderer events, and untrusted IPC responses.
- Machine files are signed, encrypted, activation-bound, and fingerprint-bound.
- The SDK caches signing keys by `kid` and can also use a pinned public key.
- As of 0.6.0, the default installation identity is a durable random UUID, scoped to the app/product and persisted in application data; existing legacy identifiers are adopted where safe. It is stable, used for seat binding and offline validation, and is not derived from hardware.
- Raw hardware fingerprint components (platform machine identifiers and a hostname fallback) are collected and sent only with the explicit `send_fingerprint_components = true` opt-in, or when a caller supplies a component map for a single machine-file checkout. Hardware-derived identity remains available through these opt-ins for legacy interoperability. Treat any collected components as device data rather than anonymous data in the host application's privacy disclosures.
---
## Swift SDK
The official Swift SDK for LicenseSeat provides a comprehensive, type-safe API for managing software licenses on Apple platforms.
> **Current stable release:** [v0.4.2](https://github.com/licenseseat/licenseseat-swift/releases/tag/v0.4.2). Review and commit the resolved `Package.resolved` version before shipping.
> **Important:** Use only a restricted publishable client key scoped to
> `licenses:validate`; assume any credential embedded in an app can be
> extracted. Never ship an administrator or server-write key. Keep end-user
> license keys out of URLs, logs, crash reports, analytics, and API responses.
## Installation
You can install the Swift SDK using the SPM (Swift Package Manager) or through Xcode:
### Swift Package Manager
Add to your `Package.swift`:
```swift
dependencies: [
.package(url: "https://github.com/licenseseat/licenseseat-swift.git", from: "0.4.2")
]
```
Then add the dependency to your target:
```swift
.target(
name: "YourApp",
dependencies: [
.product(name: "LicenseSeat", package: "licenseseat-swift")
]
)
```
### Xcode
1. File > Add Package Dependencies
2. Enter: `https://github.com/licenseseat/licenseseat-swift.git`
3. Select your version requirements and add to your target
## Quick Start
```swift
import LicenseSeat
// 1. Configure at app launch
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product" // Required
)
// 2. Activate a license without logging the key or installation identifier
_ = try await LicenseSeatStore.shared.activate("USER-LICENSE-KEY")
print("License activated")
// 3. Check status
switch LicenseSeatStore.shared.status {
case .active:
print("License active")
case .inactive:
print("No license activated")
default:
break
}
```
## Configuration
### Basic Configuration
```swift
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product"
)
```
### Advanced Configuration
```swift
let config = LicenseSeatConfig(
apiBaseUrl: "https://licenseseat.com/api/v1", // v1 API endpoint
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product", // Required for all operations
storagePrefix: "myapp_",
autoValidateInterval: 3600, // Re-validate every hour
heartbeatInterval: 300, // Heartbeat every 5 minutes
maxRetries: 3,
retryDelay: 1,
telemetryEnabled: true, // Optional diagnostics; enabled by default
offlineFallbackMode: .networkOnly, // Offline fallback strategy
maxOfflineDays: 7, // Maximum signed-token age
maxClockSkewMs: 300000, // 5-minute clock tolerance
debug: true
)
let licenseSeat = LicenseSeat(config: config)
```
Or using the builder-style API:
```swift
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product"
) { config in
config.autoValidateInterval = 3600
config.heartbeatInterval = 300
config.offlineFallbackMode = .networkOnly
config.maxOfflineDays = 7
config.debug = true
}
```
### Configuration Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `apiBaseUrl` | `String` | `https://licenseseat.com/api/v1` | v1 API endpoint |
| `apiKey` | `String?` | `nil` | Your publishable API key |
| `productSlug` | `String?` | `nil` | **Required.** Product identifier |
| `storagePrefix` | `String` | `licenseseat_` | Prefix for cache keys |
| `deviceIdentifier` | `String?` | Auto-generated | Stable custom installation ID; otherwise a random app-scoped ID is persisted |
| `autoValidateInterval` | `TimeInterval` | `3600` (1 hour) | Background validation interval |
| `heartbeatInterval` | `TimeInterval` | `300` (5 min) | Standalone heartbeat interval (0 = disabled) |
| `networkRecheckInterval` | `TimeInterval` | `30` | Offline connectivity check interval |
| `maxRetries` | `Int` | `3` | API retry attempts |
| `retryDelay` | `TimeInterval` | `1` | Base retry delay (exponential backoff) |
| `telemetryEnabled` | `Bool` | `true` | Include optional device/application telemetry on supported licensing POST requests |
| `offlineFallbackMode` | `OfflineFallbackMode` | `.networkOnly` | Offline fallback strategy |
| `offlineTokenRefreshInterval` | `TimeInterval` | `259200` (72 hours) | Legacy offline-token refresh interval |
| `maxOfflineDays` | `Int` | `0` | Maximum signed-token age and offline-authority enablement. `0` disables offline authority; enabled values are `1...36,600`, and signed expiry still applies. |
| `maxClockSkewMs` | `TimeInterval` | `300000` (5 min) | Clock tamper tolerance |
| `debug` | `Bool` | `false` | Enable debug logging |
### Offline Fallback Modes
| Mode | Description |
| ---- | ----------- |
| `.networkOnly` | Falls back only for transport failures, timeouts, and 5xx responses. Authentication, authorization, and malformed-request errors do not erase a valid cache; authoritative license-state errors do. **Recommended.** |
| `.always` | Attempts offline validation for nonterminal failures. Revoked, expired, suspended, not-active, and device-not-activated decisions remain authoritative. |
## License Lifecycle
### Activation
```swift
do {
_ = try await LicenseSeatStore.shared.activate("USER-LICENSE-KEY")
print("License activated")
} catch let error as APIError {
print("Activation failed: \(error.code ?? "unknown")")
}
```
With options:
```swift
let license = try await LicenseSeatStore.shared.activate(
"USER-LICENSE-KEY",
options: ActivationOptions(
deviceId: "custom-device-id",
deviceName: "User's MacBook Pro",
metadata: ["version": "2.0.0", "environment": "production"]
)
)
```
### Deactivation
```swift
try await LicenseSeatStore.shared.deactivate()
```
### Validation
```swift
let result = try await LicenseSeatStore.shared.validate(licenseKey: "USER-LICENSE-KEY")
if result.valid {
print("License is valid")
print("Plan: \(result.license.planKey)")
print("Status: \(result.license.status)")
// Check entitlements from validation
for entitlement in result.license.activeEntitlements {
print("Entitlement: \(entitlement.key)")
if let expiresAt = entitlement.expiresAt {
print(" Expires: \(expiresAt)")
}
}
} else {
print("Invalid: \(result.code ?? "unknown")")
print("Message: \(result.message ?? "")")
}
```
## Status Checking
### Get Current Status
```swift
switch LicenseSeatStore.shared.status {
case .inactive(let message):
print("No license: \(message)")
showActivationScreen()
case .pending(let message):
print("Validating: \(message)")
showLoadingIndicator()
case .active:
print("License active")
enableFeatures()
case .offlineValid(let details):
print("Valid offline until next sync")
enableFeatures()
showOfflineBanner()
case .invalid(let message):
print("Invalid: \(message)")
showErrorScreen(message)
case .offlineInvalid(let message):
print("Expired offline: \(message)")
showRenewalScreen()
}
```
### Status Types
| Status | Description |
| ------ | ----------- |
| `.inactive` | No license activated |
| `.pending` | License pending validation |
| `.active` | License is valid (online) |
| `.offlineValid` | License is valid (offline check) |
| `.invalid` | License is invalid |
| `.offlineInvalid` | License invalid (offline, e.g., expired) |
## Entitlements
Check feature access based on license entitlements:
```swift
let status = LicenseSeatStore.shared.entitlement("premium-features")
switch status.reason {
case nil where status.active:
enablePremiumFeatures()
case .expired:
showRenewalPrompt(expiresAt: status.expiresAt)
case .notFound:
showUpgradePrompt()
case .noLicense:
showActivationPrompt()
default:
disablePremiumFeatures()
}
// Access entitlement details
if let entitlement = status.entitlement {
print("Entitlement key: \(entitlement.key)")
if let metadata = entitlement.metadata {
print("Metadata: \(metadata)")
}
}
```
### Reactive Entitlement Monitoring
```swift
LicenseSeatStore.shared.entitlementPublisher(for: "api-access")
.receive(on: DispatchQueue.main)
.sink { status in
apiAccessEnabled = status.active
if let expiresAt = status.expiresAt {
scheduleExpirationWarning(at: expiresAt)
}
}
.store(in: &cancellables)
```
## SwiftUI Integration
### Property Wrappers
The SDK provides property wrappers for reactive SwiftUI apps:
```swift
import SwiftUI
import LicenseSeat
@main
struct MyApp: App {
init() {
LicenseSeatStore.shared.configure(
apiKey: ProcessInfo.processInfo.environment["LICENSESEAT_API_KEY"] ?? "",
productSlug: "my-app"
)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@LicenseState private var status // Auto-updates on license changes
@EntitlementState("pro") private var hasPro // Feature flag
var body: some View {
switch status {
case .active, .offlineValid:
MainAppView()
.environment(\.proEnabled, hasPro)
case .inactive:
ActivationView()
case .invalid(let message):
ErrorView(message: message)
case .pending:
ProgressView("Validating...")
case .offlineInvalid:
ExpiredView()
}
}
}
struct ActivationView: View {
@State private var licenseKey = ""
@State private var isLoading = false
@State private var error: String?
var body: some View {
Form {
TextField("License Key", text: $licenseKey)
Button("Activate") {
Task {
isLoading = true
defer { isLoading = false }
do {
try await LicenseSeatStore.shared.activate(licenseKey)
} catch let apiError as APIError {
self.error = apiError.message
} catch {
self.error = error.localizedDescription
}
}
}
.disabled(licenseKey.isEmpty || isLoading)
if let error {
Text(error).foregroundColor(.red)
}
}
}
}
```
## UIKit / AppKit Integration
Use Combine publishers for reactive updates:
```swift
import LicenseSeat
import Combine
class LicenseManager: ObservableObject {
@Published var isLicensed = false
@Published var hasProFeatures = false
private var cancellables = Set()
init() {
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product"
)
// React to license status changes
LicenseSeatStore.shared.$status
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
switch status {
case .active, .offlineValid:
self?.isLicensed = true
default:
self?.isLicensed = false
}
}
.store(in: &cancellables)
// Monitor specific entitlements
LicenseSeatStore.shared.entitlementPublisher(for: "pro-features")
.map { $0.active }
.receive(on: DispatchQueue.main)
.assign(to: &$hasProFeatures)
}
func activate(_ key: String) async throws {
try await LicenseSeatStore.shared.activate(key)
}
func deactivate() async throws {
try await LicenseSeatStore.shared.deactivate()
}
}
```
## Event System
Subscribe to SDK events for analytics, UI updates, or custom logic:
```swift
// Subscribe with closure (returns AnyCancellable)
let cancellable = licenseSeat.on("activation:success") { data in
print("License activated!")
Analytics.track("license_activated")
}
// Unsubscribe by cancelling
cancellable.cancel()
// Or use Combine publishers
licenseSeat.eventPublisher
.filter { $0.name.hasPrefix("validation:") }
.sink { event in
switch event.name {
case "validation:success":
updateUI()
case "validation:offline-success":
showOfflineBanner()
case "license:revoked":
lockFeatures()
default:
break
}
}
.store(in: &cancellables)
```
### Available Events
| Event | Description |
| ----- | ----------- |
| `activation:start/success/error` | License activation lifecycle |
| `validation:start/success/failed/error` | Online validation |
| `validation:offline-success/offline-failed` | Offline validation |
| `deactivation:start/success/error` | License deactivation |
| `license:loaded` | Cached license loaded at startup |
| `license:revoked` | License revoked by server |
| `offlineToken:verified` | Legacy offline token signature verified |
| `offlineToken:verificationFailed` | Legacy offline token verification failed |
| `heartbeat:success` | Heartbeat acknowledged by server |
| `heartbeat:error` | Heartbeat request failed |
| `autovalidation:cycle` | Auto-validation cycle triggered |
| `network:online/offline` | Connectivity changes |
| `sdk:reset` | SDK state cleared |
## Offline Validation
The SDK provides seamless offline support with Ed25519 cryptographic verification:
> **Note:** The current Swift SDK offline flow still uses signed offline tokens. Machine files are the newer preferred offline artifact at the API level, but the Swift SDK has not migrated to them yet. Treat offline tokens as the current Swift implementation detail, not the long-term product direction.
Offline authority is disabled by default. Set `maxOfflineDays` to an explicit
value in `1...36,600` only after choosing the maximum outage window your
product accepts. Zero and out-of-range values fail closed; the signed token,
underlying license, and entitlement expirations remain additional upper bounds.
```swift
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product"
) { config in
config.offlineFallbackMode = .networkOnly // Network-first fallback mode
config.maxOfflineDays = 7 // Maximum age from signed iat
config.offlineTokenRefreshInterval = 259200 // Refresh every 72 hours
}
```
### How Offline Validation Works
1. On activation, the SDK fetches a legacy signed offline token from the server
2. The token contains license data, entitlements, and an Ed25519 signature
3. When offline, the SDK verifies the signature locally
4. Clock tamper detection prevents users from bypassing expiration
### Security Features
- **Ed25519 Signatures**: Offline licenses are cryptographically signed
- **Clock Tamper Detection**: Detects system clock manipulation
- **Constant-Time Comparison**: Prevents timing attacks on license keys
- **Signed Expiry**: The token and underlying license expiry are always enforced
- **Explicit Offline Authority**: `maxOfflineDays` must be `1...36,600` and imposes an app-side age cap without extending signed expiry; `0` disables offline grants
- **Protected Cache**: Apple platforms use `AfterFirstUnlockThisDeviceOnly` Keychain items and safely migrate 0.4.x plaintext state
### Manual Offline Methods
```swift
// Sync offline assets (downloads the legacy token + signing key, caches them)
await LicenseSeat.shared.syncOfflineAssets()
// Verify cached offline token (use when offline)
let result = await LicenseSeat.shared.verifyCachedOffline()
// ValidationResponse with valid/code/license fields
```
## API Response Format
The v1 API uses Stripe-style conventions with `object` fields identifying response types.
### Activation Response
```json
{
"object": "activation",
"id": 12345,
"fingerprint": "mac_abc123",
"device_name": "User's MacBook",
"license_key": "LICENSE-KEY",
"activated_at": "2025-01-15T10:30:00Z",
"license": {
"object": "license",
"key": "LICENSE-KEY",
"status": "active",
"mode": "hardware_locked",
"plan_key": "pro",
"seat_limit": 5,
"active_seats": 1,
"active_entitlements": [
{"key": "premium", "expires_at": null, "metadata": null}
],
"product": {"slug": "my-app", "name": "My App"}
}
}
```
### Deactivation Response
```json
{
"object": "deactivation",
"activation_id": 12345,
"deactivated_at": "2025-01-15T12:00:00Z"
}
```
## Error Handling
```swift
do {
try await LicenseSeatStore.shared.activate("INVALID-KEY")
} catch let error as APIError {
print("Error code: \(error.code ?? "unknown")")
print("Message: \(error.message)")
print("Details: \(error.details ?? [:])")
}
```
### Common Error Codes
| Code | Description |
| ---- | ----------- |
| `license_not_found` | License key doesn't exist |
| `license_expired` | License has expired |
| `license_suspended` | License has been suspended |
| `seat_limit_exceeded` | No available seats |
| `device_not_activated` | Installation fingerprint has not been activated for this license |
| `product_mismatch` | License not valid for this product |
## Telemetry
The SDK includes the following optional telemetry object by default on supported
licensing POST requests:
| Field | Source |
|-------|--------|
| `sdk_name` | Always `swift` |
| `sdk_version` | `LicenseSeatConfig.sdkVersion` |
| `os_name` | `macOS`, `iOS`, `tvOS`, `watchOS`, `visionOS` |
| `os_version` | `ProcessInfo.operatingSystemVersion` |
| `platform` | `native` |
| `device_model` | `sysctlbyname("hw.model")` |
| `app_version` | `CFBundleShortVersionString` |
| `app_build` | `CFBundleVersion` |
| `device_type` | `desktop`, `phone`, `tablet`, `watch`, `tv`, `headset` |
| `architecture` | `arm64` or `x64` (compile-time) |
| `cpu_cores` | `ProcessInfo.processorCount` |
| `memory_gb` | `ProcessInfo.physicalMemory` (rounded to nearest GB) |
| `locale` | `Locale.current.identifier` |
| `language` | 2-letter code extracted from locale |
| `timezone` | `TimeZone.current.identifier` |
| `screen_resolution` | Native pixel resolution (macOS/iOS only) |
| `display_scale` | `NSScreen.backingScaleFactor` / `UIScreen.scale` |
See [[api-reference/telemetry|Telemetry]] for the full field reference.
Disable the optional object when the host application does not need LicenseSeat
analytics:
```swift
LicenseSeatStore.shared.configure(
apiKey: "pk_live_xxxxxxxx",
productSlug: "your-product"
) { config in
config.telemetryEnabled = false
}
```
Core licensing is not anonymous and is not disabled by this setting. Activation,
validation, heartbeat, deactivation, and offline-asset requests still send the
license/product context and the app-scoped installation fingerprint. The server
also observes the source IP and records successful licensing activity. Swift
0.4.2 creates a random app-scoped installation identifier and protects it in
Keychain on Apple platforms; it does not derive the default from a hardware UUID.
Treat these persistent identifiers and interactions as potentially linked data
in the host application's privacy policy and marketplace disclosures.
## Platform Support
| Platform | Minimum Version | Notes |
| -------- | --------------- | ----- |
| macOS | 12.0+ | Full support with a Keychain-protected app-scoped installation ID |
| iOS | 13.0+ | Full support |
| tvOS | 13.0+ | Full support |
| watchOS | 8.0+ | Core features (no Network.framework) |
## Debug Report
Generate a diagnostic report for support:
```swift
let report = LicenseSeatStore.shared.debugReport()
print(report)
// Contains: SDK version, status, license key prefix (redacted), timestamps, etc.
```
## Reset SDK
Clear cached licensing state and reset to the initial status:
```swift
LicenseSeatStore.shared.reset()
```
Reset intentionally retains the app-scoped installation identifier so a normal
reset/reactivation does not consume a new seat. Supply a deliberate custom
`deviceIdentifier` only when the application owns its persistence and migration
contract.
## Next Steps
- [[sdks/javascript|JavaScript SDK]] - For web applications
- [[guides/offline-licensing|Offline Licensing Guide]] - In-depth offline documentation
- [[guides/air-gapped-licensing|Air-Gapped Licensing]] - For devices without any internet
- [[api-reference/index|API Reference]] - Direct API access
---