Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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:

PropType
key?string

The license key as the customer received it.

Typestring
activationId?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.

Typestring
label?string

Activate only. Names the device or instance being activated.

Typestring
baseUrl?string

Used verbatim; overrides server where a server option exists.

Typestring
fetch?typeof fetch

Custom fetch implementation.

Typetypeof fetch
signal?AbortSignal

Abort the request.

TypeAbortSignal

Plus 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.

PropType
valid?boolean

The provider's verdict. Always authoritative — unlike the locally derived status.

Typeboolean
licenseKey?LicenseKey

The key. Always undefined on Dodo Payments, and undefined whenever valid is false.

TypeLicenseKey
activation?LicenseKeyActivation

The matched activation, when the call supplied or created one.

TypeLicenseKeyActivation
raw?unknown

The untouched provider payload.

Typeunknown

Only 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:

  • PolarorganizationId is required. It is a public identifier, safe to ship inside the app, and Polar scopes the check server-side against it.
  • Lemon Squeezyexpect: { 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 Paymentscannot be scoped. Validate answers a bare { valid }, so LicenseKeyValidation.licenseKey is always undefined and only activateLicenseKey reveals the business and product on rawdetails.

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 });
PropType
id?string

The license key ID — LicenseKey.id, not the key string.

Typestring
disabled?boolean

Revokes the key while leaving it in place; false re-enables it.

Typeboolean
activationLimit?number | null

Maximum simultaneous activations. null removes the limit.

Typenumber | null
expiresAt?Date | null

When the key stops working. null removes the expiry.

TypeDate | null
signal?AbortSignal

Abort the request.

TypeAbortSignal

An 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:

PropType
id?string

The identifier the merchant methods take.

Typestring
key?string

The key itself. Merchant reads may return a masked form — check raw.

Typestring
status?'active' | 'disabled' | 'expired'

Normalized status. valid from a validation call always wins.

Type'active' | 'disabled' | 'expired'
activationLimit?number

Maximum simultaneous activations; absent when unlimited.

Typenumber
activationCount?number

Activations currently in use.

Typenumber
expiresAt?Date

When the key stops working.

TypeDate
customerId?string

The provider's customer identifier.

Typestring
productId?string

Set on Dodo Payments, and on the Lemon Squeezy public path. Never on Polar.

Typestring
raw?unknown

The untouched provider payload.

Typeunknown

All 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.

Last updated on August 8, 2026

Was this page helpful?