License keys
User-typeable keys that grant entitlements on any install. Works like a Steam key — one purchase, one key, N seats. Offline-validatable, revocable in under a minute.
License keys let you sell Pro outside the Chrome Web Store — on a landing page, at a trade show, or as part of a bundle. The user types a CRXP-XXXX-XXXX-XXXX-XXXX-XXXX key into your popup, the SDK activates it, and the entitlement grant propagates to hasEntitlement() instantly.
Keys are signed with Ed25519, revocable from the dashboard, and support seats (one key = N installs) plus optional device lock (seats bind to the first machine to activate).
Quickstart
Enable the feature and install a signing keypair on your API deployment:
# Generate the keypair once, keep the private seed secret.
node apps/api/scripts/generate-license-signing-keypair.mjs
wrangler secret put LICENSE_KEYS # set to "1"
wrangler secret put LICENSE_SIGNING_PRIVATE_KEY
wrangler secret put LICENSE_SIGNING_PUBLIC_KEY
Create a one-time price in the dashboard and tick Sell via license key. Pick seats, expiry (never / 1y / 90d), and optional device lock. Buyers receive the raw key by email; the key is shown in the dashboard exactly once.
Activate a key from the SDK
import { CrxPay } from '@crxpay/sdk';
// User pastes their key into your popup
const result = await CrxPay.activateKey('CRXP-XXXX-XXXX-XXXX-XXXX-XXXX');
if (result.ok) {
// result.data.seat = { used: 1, of: 3 }
console.log('activated', result.data.seat);
} else {
// 'invalid_key' | 'key_revoked' | 'key_expired' |
// 'no_seats_available' | 'device_locked'
console.error(result.error.code);
}
The SDK runs an offline Crockford + sha256 checksum before the network call, so mistyped keys never hit the wire. After a successful activation the key is persisted in chrome.storage.local and the subscription cache is refreshed, so hasEntitlement reflects the grant immediately.
Offline validation
Every subscription read is served from a signed local cache first. The HMAC covers data, timestamp, and TTL, so a tampered entry is discarded silently and refetched.
On boot the SDK also re-checksums the persisted license key. If the bytes have been mangled (disk corruption, user export/reimport) the key is evicted and the cache is invalidated — the next getSubscription() goes to the network to re-derive state from scratch.
Revocation and the 60-second promise
Revoke a key from the dashboard (row actions → Revoke). The server flips status=revoked immediately.
Active installs pick up the change within 60 seconds because license-backed subscriptions use a shorter signed-cache TTL (60s) and a 1-minute background alarm. The next popup open or the next alarm tick — whichever fires first — triggers a network refresh and the SDK flips to locked. Stripe subscribers keep your configured refresh cadence (hourly by default), so the shorter TTL has zero cost for non-license users.
Signing-key rotation
The Ed25519 keypair in LICENSE_SIGNING_* signs activation-response bundles. Rotate by staging a new keypair on *_NEXT, cutting an SDK release that trusts both keys, then promoting NEXT → primary.
- Generate the new keypair:
node apps/api/scripts/generate-license-signing-keypair.mjs --next. - Set
LICENSE_SIGNING_PRIVATE_KEY_NEXTandLICENSE_SIGNING_PUBLIC_KEY_NEXTviawrangler secret put. - Ship an SDK release whose baked-in public keys include both current and next. Wait for end users to upgrade.
- Promote: set
LICENSE_SIGNING_PRIVATE_KEY/LICENSE_SIGNING_PUBLIC_KEYto the new keypair and clear the*_NEXTsecrets. - Ship a follow-up SDK release that drops the old public key once the old keypair has left the field.
The dashboard Signing keys panel on your Licenses page shows the current + staged public keys so you can verify env state before and after each step.
API reference
| Method | Returns |
|---|---|
CrxPay.activateKey(key, opts?) | { seat, expiresAt, entitlements } |
CrxPay.deactivateKey(key) | { deactivated: true } |
CrxPay.getSubscription() | Subscription (includes seat + licenseBacked: true if active via key) |
CrxPay.hasEntitlement(id) | boolean (synchronous, cache-only) |
Was this page helpful?
Your feedback shapes what we document next.