License keys
Validate, activate and deactivate license keys with no credential from the app you ship, and manage them from your server with client.licenseKeys.
A license key unlocks software the customer runs themselves — a desktop app, a CLI, a plugin. That
splits the feature in two, and revenue-sdk keeps the halves apart on purpose.
// In the app you ship. No API key anywhere.
import { validateLicenseKey } from 'revenue-sdk/polar';
const { valid } = await validateLicenseKey({ key, organizationId: 'ORGANIZATION_ID' });
// On your server, with the merchant credential.
const licenseKey = await client.licenseKeys.get({ id: 'LICENSE_KEY_ID' });
Checking a key — validate, activate, deactivate — happens inside the shipped application, through
standalone functions exported from the provider subpath that take no credential. Managing keys —
list, get, disable, re-limit — happens on your server through client.licenseKeys, gated by the
licenseKeys capability.
That split is a security boundary. These three routes are the only ones in the SDK that need no secret;
hanging them off createClient would teach that validating a license needs the merchant key, and the
next step is that key shipping inside a desktop binary, where anyone can extract it and call every other
endpoint it unlocks.
Validate, activate, deactivate
Every supporting provider exports the same three functions from its own subpath, with the same parameters:
key?string
The license key as the customer received it.
stringactivationId?string
Required on deactivate; on validate it narrows the check to one activation and returns it. Lemon Squeezy calls this an instance — the SDK name is uniform.
stringlabel?string
Activate only. Names the device or instance being activated.
stringbaseUrl?string
Used verbatim; overrides server where a server option exists.
stringfetch?typeof fetch
Custom fetch implementation.
typeof fetchsignal?AbortSignal
Abort the request.
AbortSignalPlus one scoping parameter each — see Scoping below — and, on Polar and
Dodo Payments, the same server option their factory takes.
import { validateLicenseKey } from 'revenue-sdk/polar';
const { valid } = await validateLicenseKey({
key: 'POLAR-KEY-0001',
organizationId: 'ORGANIZATION_ID', // required
activationId: 'ACTIVATION_ID', // optional — narrows the check to one device
});import { validateLicenseKey } from 'revenue-sdk/lemon-squeezy';
const { valid } = await validateLicenseKey({
key: 'LICENSE-KEY',
expect: { storeId: 'STORE_ID', variantId: 'VARIANT_ID' }, // required
activationId: 'ACTIVATION_ID',
});import { validateLicenseKey } from 'revenue-sdk/dodo-payments';
const { valid } = await validateLicenseKey({
key: 'LICENSE-KEY',
server: 'test', // 'live' (default) | 'test'
activationId: 'ACTIVATION_ID',
});activateLicenseKey and deactivateLicenseKey take the same scoping parameter. The return types are
identical on all three subpaths: LicenseKeyValidation, LicenseKeyActivation, and void.
valid?boolean
The provider's verdict. Always authoritative — unlike the locally derived status.
booleanlicenseKey?LicenseKey
The key. Always undefined on Dodo Payments, and undefined whenever valid is false.
LicenseKeyactivation?LicenseKeyActivation
The matched activation, when the call supplied or created one.
LicenseKeyActivationraw?unknown
The untouched provider payload.
unknownOnly validateLicenseKey reports a rejection as data. A key that is unknown, revoked, disabled,
expired or owned by another merchant comes back as valid: false and never throws. Activation and
deactivation carry no verdict field, so a refusal throws a RevenueError instead: a reached activation
limit as validation on every provider, and a key that is not yours as not_found.
Scoping differs per provider
Proving that a key is yours is the security question on this page, and the three providers answer it differently:
- Polar —
organizationIdis required. It is a public identifier, safe to ship inside the app, and Polar scopes the check server-side against it. - Lemon Squeezy —
expect: { storeId, productId?, variantId? }is required, because the public license API takes only the key and any merchant’s key would otherwise validate against your app. The SDK asserts it on all three calls and fails closed — details. - Dodo Payments — cannot be scoped. Validate answers a bare
{ valid }, soLicenseKeyValidation.licenseKeyis alwaysundefinedand onlyactivateLicenseKeyreveals the business and product onraw— details.
Status is normalized, valid is authoritative
LicenseKeyStatus is a closed union of three values:
| Unified | Polar | Lemon Squeezy | Dodo Payments |
|---|---|---|---|
active |
granted |
active, inactive¹ |
active |
disabled |
revoked, disabled |
disabled |
disabled |
expired |
*derived from expires_at*² |
expired |
expired |
¹ Lemon Squeezy’s inactive means “issued but never activated” — a perfectly usable key.
² Polar has no expired status, so the SDK derives it from expires_at. That derivation can never
contradict a verdict: Polar rejects expired, revoked and disabled keys server-side on validate, so it
only ever shows up on merchant reads.
When both are available, branch on valid — it is the provider’s answer, not a local guess.
Managing keys from your server
client.licenseKeys is the merchant half. It needs the credential, so it belongs on your server — never
in the app you ship.
const { items, cursor } = await client.licenseKeys.list({ limit: 20 });
const licenseKey = await client.licenseKeys.get({ id: 'LICENSE_KEY_ID' });
for await (const key of client.licenseKeys.listAll()) {
console.log(key.key, key.status, key.activationCount);
}
// Revoke a key after a chargeback, without deleting it.
await client.licenseKeys.update({ id: 'LICENSE_KEY_ID', disabled: true });
// Or hand out more seats and drop the expiry.
await client.licenseKeys.update({ id: 'LICENSE_KEY_ID', activationLimit: 5, expiresAt: null });
id?string
The license key ID — LicenseKey.id, not the key string.
stringdisabled?boolean
Revokes the key while leaving it in place; false re-enables it.
booleanactivationLimit?number | null
Maximum simultaneous activations. null removes the limit.
number | nullexpiresAt?Date | null
When the key stops working. null removes the expiry.
Date | nullsignal?AbortSignal
Abort the request.
AbortSignalAn empty id, and an activationLimit that is neither null nor a positive integer, are rejected with
RevenueError { code: 'validation' } before any request is made.
The LicenseKey model:
id?string
The identifier the merchant methods take.
stringkey?string
The key itself. Merchant reads may return a masked form — check raw.
stringstatus?'active' | 'disabled' | 'expired'
Normalized status. valid from a validation call always wins.
'active' | 'disabled' | 'expired'activationLimit?number
Maximum simultaneous activations; absent when unlimited.
numberactivationCount?number
Activations currently in use.
numberexpiresAt?Date
When the key stops working.
DatecustomerId?string
The provider's customer identifier.
stringproductId?string
Set on Dodo Payments, and on the Lemon Squeezy public path. Never on Polar.
stringraw?unknown
The untouched provider payload.
unknownAll four methods throw unsupported on a provider without the licenseKeys capability — Stripe and
Paddle, neither of which has a license-key API at all. Branch on client.capabilities.licenseKeys in
provider-agnostic code; see the capability matrix.
Getting notified
One license event is normalized: license.issued, emitted when a provider hands a key to a
customer.
| Provider | Provider event | licenseKeyId |
licenseKey |
|---|---|---|---|
| Polar | benefit_grant.created, only when data.benefit.type is license_keys |
set | — (only a masked display_key) |
| Lemon Squeezy | license_key_created |
set | set, with the key itself |
| Dodo Payments | license_key.created |
set | set, with the key itself |
Two fields rather than one, on purpose: licenseKeyId is the uniform access path, set on every
license.issued event, while licenseKey carries the full record only where the provider actually
sends it. That makes the follow-up fetch explicit instead of silently handing you a masked key:
const event = await parseWebhookEvent({ headers, body });
if (event.type === 'license.issued') {
// Set everywhere except Polar, whose grant carries only a masked display_key.
const licenseKey = event.licenseKey ?? (await client.licenseKeys.get({ id: event.licenseKeyId }));
await emailTheKey(licenseKey.key);
}
Polar has no license webhook at all: a key arrives as a benefit grant, so license.issued is decided
from data.benefit.type rather than from the event string, and a grant for a Discord, downloadable or
meter-credit benefit stays unknown. Stripe and Paddle emit nothing. There is no license.revoked
event on any provider — enforce revocation with validateLicenseKey instead, as below. Full mapping:
Webhook events.
Shipping the check in your app
Activate once, validate on every later start, deactivate when the customer signs out. Store the key and the activation ID locally — the activation ID is what frees the seat again.
import {
activateLicenseKey,
deactivateLicenseKey,
validateLicenseKey,
} from 'revenue-sdk/polar';
// A public identifier. Shipping it inside the app is the point — it is what scopes
// the check to your organization server-side.
const ORGANIZATION_ID = 'ORGANIZATION_ID';
// First run: the customer pastes the key from their purchase email.
export async function activate(key: string): Promise<void> {
const activation = await activateLicenseKey({
key,
organizationId: ORGANIZATION_ID,
label: deviceName(), // e.g. os.hostname()
});
await settings.set({ key, activationId: activation.id });
}
// Every later start: check the key and the activation this device holds.
export async function isLicensed(): Promise<boolean> {
const stored = await settings.get();
if (!stored) {
return false;
}
const { valid } = await validateLicenseKey({
...stored, // key + activationId
organizationId: ORGANIZATION_ID,
signal: AbortSignal.timeout(5_000),
});
return valid;
}
// Sign-out: release the seat so the customer can use another machine.
export async function deactivate(): Promise<void> {
const stored = await settings.get();
if (stored) {
await deactivateLicenseKey({ ...stored, organizationId: ORGANIZATION_ID });
await settings.clear();
}
}
Passing activationId to validateLicenseKey is what makes the check per-device: without it the key
validates on any machine, including ones that were never activated or have since been deactivated.
Enforce with this check, not with a webhook. All three providers reject revoked, disabled and expired keys server-side on validate, so the shipped app downgrades itself on its first start after a revocation without any event being involved. A webhook only ever reaches your server; it has nothing to say about a laptop that was offline when the revocation happened and reconnects a week later.
Use the subscription events for bookkeeping. When a key is tied to a subscription — the usual case —
the cause of a revocation is the subscription ending, and those events are normalized on all five
providers. Handle subscription.canceled (terminal) and a subscription.updated whose
subscriptionChange is cancel_scheduled to record why access ended; the app learns that it ended
from its next validate call. See Subscription lifecycle.
Testing
The in-memory provider seeds licenseKeys and exposes them on state.licenseKeys; it deliberately does
not implement the three standalone functions, which are subpath exports rather than RevenueProvider
methods. See Testing provider.
Out of scope
- Key creation — only Dodo Payments can mint a key through its API.
- Issuance configuration — the Polar benefit, the Lemon Squeezy variant setting, the Dodo Payments entitlement are all dashboard settings.
- Every license webhook event except issuance, and Polar’s usage metering on keys.