license-keys — Claude Code skill: what it does, how to install
Sets up Dodo Payments software license management in desktop apps and CLIs. Enforces per-seat or per-device activation limits.
npx skills add dodopayments/skills --skill license-keys -a claude-codeWhat license-keys does
The skill teaches your coding agent how to handle Dodo Payments software licensing through its TypeScript SDK. It clearly splits public client-side calls from protected merchant endpoints. The client.licenses.* methods handle tasks like activate and validate. They run with a placeholder bearer token such as 'public', so you never embed your secret API key inside distributed builds.
For backend dashboards, the skill guides the agent away from deprecated client.licenseKeys methods toward client.entitlements.grants.*. Key validity integrates with subscription status automatically. Paused or refunded accounts disable licenses without manual intervention. When a user exceeds allowed seats, the API responds with HTTP 422, making limit checks straightforward to catch.
What it helps with
- implement client-side key activation and instance verification without leaking secret keys
- catch HTTP 422 errors when concurrent activation caps are reached
- manage merchant-side license retrieval using
client.entitlements.grants - link software access directly to Dodo Payments subscription status changes
Example prompts for the agent
- Implement device activation for Dodo Payments using
client.licenses.activatein TypeScript - Show how to validate an existing license key using
instanceIdwithout exposing secret tokens - Fetch all active license keys for a customer using
listEntitlementGrants
How to install license-keys
1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill license-keys -a claude-code2Manually: put the files in place
~/.claude/skills/license-keys/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/license-keys/
1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill license-keys -a codex2Manually: put the files in place
.agents/skills/license-keys/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill license-keys -a cursor2Manually: put the files in place
.cursor/skills/license-keys/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install dodopayments/skills license-keys2Manually: put the files in place
.github/skills/license-keys/SKILL.md1Skills CLI (Vercel)
npx skills add dodopayments/skills --skill license-keys -a gemini-cli2Platform CLI
gemini skills install https://github.com/dodopayments/skills --path dodo-payments/license-keysManually: put the files in place
.devin/skills/license-keys/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add dodopayments/skills --skill license-keys -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about license-keys
How to install license-keys in Claude Code?
Run npx skills add dodopayments/skills --skill license-keys -a claude-code in your terminal. The skill definition will be placed in ~/.claude/skills/license-keys/SKILL.md.
What does the license-keys skill do?
It provides patterns and SDK flows for gating software behind Dodo Payments licenses. It covers device-level instances, seat caps, and grant revocation.
How does it handle merchant vs client-side API authentication?
End-user device calls use public client.licenses.* routes with a placeholder token. Administrative tasks, such as listing or revoking grants, run on the server using process.env.DODO_PAYMENTS_API_KEY.
Source text
SKILL.md · MIT
---
name: license-keys
description: Dodo Payments license keys for software products, covering activation, validation, and deactivation via client.licenses, activation limits, expiry, entitlement grants, revocation, and license webhooks. Use when gating a desktop app, CLI, plugin, or SaaS feature behind a license key, enforcing per-seat or per-device limits, or building a license dashboard.
---
# Dodo Payments License Keys
License keys authorize access to your digital products. Use them for software licensing, per-seat controls, and gating premium features.
## When to use this skill
- Implementing end-user license activation and validation flows
- Building merchant dashboards to manage customer license keys
- Handling license expiry, activation limits, and subscription-linked revocation
- Integrating license checks into desktop apps, CLIs, or web services
- Reacting to license lifecycle webhooks
## Core concepts
**License Key Entitlements** deliver keys when a product is purchased. The entitlement config supplies activation limits, optional duration, and fulfillment mode (auto or manual).
**Three distinct resources:**
1. **`client.licenses.*`** — end-user activation flow (public, no API key required)
- `activate()` — activate a key on a device
- `validate()` — check if a key is valid
- `deactivate()` — free an activation slot
2. **Entitlement grants** — merchant-side reads and revocation (requires API key)
- `client.customers.listEntitlementGrants()`, `client.entitlements.grants.list()` — list issued keys (each license-key grant carries a `license_key` object)
- `client.entitlements.grants.revoke()` — revoke a key
- `client.licenseKeys.create()` — still supported, for **importing** existing keys
- `client.licenseKeys.list()` / `retrieve()` / `update()` are **deprecated** (`GET /license_keys`, `GET`/`PATCH /license_keys/{id}`); use the grant endpoints above instead
3. **`client.licenseKeyInstances.*`** — per-device activation instances (requires API key)
- `list()`, `retrieve()`, `update()` — track active devices per key
**Activation limits:** blank/null means unlimited; otherwise it's the maximum concurrent activations. Attempting to activate beyond the limit returns `422`.
**Expiry semantics:**
- One-time-payment keys honor the entitlement duration.
- Subscription-issued keys have no independent expiry; Dodo drives their validity from subscription state **automatically**: `past_due` leaves them active, `on_hold` and `paused` disable them until the subscription recovers or resumes, `cancelled`/`expired` disable them permanently, and `plan_changed` replaces them. A one-time `refund.succeeded` also disables them. You do not need to disable keys yourself.
- Imported keys use nullable `expires_at`; null means perpetual.
---
## End-User Activation Flow
### Activate a license key
```typescript
import DodoPayments from 'dodopayments';
// The license endpoints are public and don't check the token, but the SDK
// constructor requires a value. Never ship your secret API key in client software.
// Set the environment explicitly: the SDK defaults to live_mode.
const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' });
async function activateLicense(licenseKey: string, deviceName: string) {
try {
const response = await client.licenses.activate({
license_key: licenseKey,
name: deviceName, // e.g., "John's MacBook Pro"
});
return {
success: true,
instanceId: response.id,
customerId: response.customer.customer_id,
productId: response.product.product_id,
};
} catch (error: any) {
if (error.status === 422) {
return { success: false, error: 'Activation limit reached' };
}
return { success: false, error: error.message || 'Activation failed' };
}
}
```
Response includes `id` (instance ID), `business_id`, `name`, `license_key_id`, `created_at`, customer details, and product details.
### Validate a license key
```typescript
import DodoPayments from 'dodopayments';
const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' }); // public endpoints: placeholder token; use 'live_mode' in production
async function validateLicense(licenseKey: string, instanceId: string) {
try {
const response = await client.licenses.validate({
license_key: licenseKey,
// Also checks that THIS device's activation still exists, so a
// deactivated device stops validating even though the key is active.
license_key_instance_id: instanceId,
});
return { valid: response.valid };
} catch (error) {
return { valid: false };
}
}
```
Without `license_key_instance_id`, validation only checks that the key is `active` and unexpired. Pass the instance ID you stored at activation.
### Deactivate a license
```typescript
import DodoPayments from 'dodopayments';
const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' }); // public endpoints: placeholder token; use 'live_mode' in production
async function deactivateLicense(licenseKey: string, instanceId: string) {
try {
await client.licenses.deactivate({
license_key: licenseKey,
license_key_instance_id: instanceId,
});
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
```
---
## Merchant-Side Key Management
### List a customer's license keys
`client.licenseKeys.list()`, `retrieve()`, and `update()` are deprecated. Read keys through the entitlement grant endpoints; each license-key grant carries a `license_key` object (`key`, `status`, `expires_at`, `activations_used`, `activations_limit`), which is `null` on a manual-mode grant still `Pending`.
```typescript
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
});
async function listCustomerKeys(customerId: string) {
const keys = [];
for await (const grant of client.customers.listEntitlementGrants(customerId, {
integration_type: 'license_key',
status: 'Delivered',
})) {
if (!grant.license_key) continue;
keys.push({
grantId: grant.id,
entitlementId: grant.entitlement_id,
key: grant.license_key.key,
status: grant.license_key.status,
expiresAt: grant.license_key.expires_at,
activationsUsed: grant.license_key.activations_used,
activationsLimit: grant.license_key.activations_limit,
});
}
return keys;
}
```
To list every key issued for one License Key entitlement, use `client.entitlements.grants.list('ent_...', { status: 'Delivered' })`.
### Revoke a license key
```typescript
// Disables the key with revocation_reason: manual. Manually revoked keys are
// not re-granted on subscription renewal.
await client.entitlements.grants.revoke('entg_abc123', { id: 'ent_license_key_id' });
```
### Import a license key (`POST /license_keys`)
Use this to migrate keys from another system. Imported keys do **not** trigger a customer email; notify the customer yourself. For keys Dodo issues, Dodo emails them.
```typescript
const newKey = await client.licenseKeys.create({
customer_id: 'cus_abc123',
key: 'PREMIUM-AAAA-BBBB-CCCC',
product_id: 'pdt_abc123',
activations_limit: 5,
expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
});
console.log(newKey.key); // The actual license key string
```
---
## List Activation Instances
```typescript
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
});
// List all instances for a specific license key
for await (const instance of client.licenseKeyInstances.list({
license_key_id: 'lk_abc123',
})) {
console.log({
id: instance.id,
name: instance.name,
createdAt: instance.created_at,
});
}
```
Optional query fields: `page_size` (default 10, max 100), `page_number` (default 0), `license_key_id`, and `grant_id`.
---
## Desktop App Integration
Full guide: [references/desktop-app.md](references/desktop-app.md).
Covers:
- Electron app example
- React component for license input
## CLI Tool Integration
Full guide: [references/cli-tool.md](references/cli-tool.md).
Covers:
- Node.js CLI example
- CLI commands
## Webhook Integration
### Handle license key delivery
Dodo generates **and emails** each auto-fulfilled key to the customer (including the entitlement's activation message), and it disables, re-enables, and replaces keys as the subscription changes state. Your webhook only needs to mirror that state for your own records - do not email keys yourself and do not disable keys on cancellation.
When a product with licensing enabled is purchased, an auto-fulfilled key arrives as `entitlement_grant.created` with `status: "Delivered"` and a `license_key` object; no separate `entitlement_grant.delivered` follows. `entitlement_grant.delivered` fires only when a grant moves to `Delivered` later (for example, when you manually fulfill a `Pending` grant, or a revoked grant is restored), so handle both:
```typescript
// app/api/webhooks/dodo/route.ts
import { NextRequest, NextResponse } from 'next/server';
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
environment: process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode',
});
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const event = client.webhooks.unwrap(body, {
headers: {
'webhook-id': req.headers.get('webhook-id') || '',
'webhook-signature': req.headers.get('webhook-signature') || '',
'webhook-timestamp': req.headers.get('webhook-timestamp') || '',
},
});
if (
(event.type === 'entitlement_grant.created' || event.type === 'entitlement_grant.delivered') &&
event.data.status === 'Delivered' &&
event.data.license_key // other entitlement types (and Pending manual grants) have no key
) {
// Dodo emails the key to the customer itself, so there is no email to send here.
await mirrorGrant(event.data, 'active');
}
if (event.type === 'entitlement_grant.revoked') {
// Dodo already disabled the key (cancellation, expiry, refund, on_hold,
// pause, plan change, or manual revoke). Mirror it locally.
await mirrorGrant(event.data, 'revoked');
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
}
type Grant = DodoPayments.Entitlements.EntitlementGrant;
// Webhooks can arrive out of order and be retried concurrently. Apply a grant
// only when it is newer than the stored copy (by the grant's own updated_at),
// using a conditional UPDATE so a stale "delivered" can never overwrite a newer
// "revoked". If no row exists yet - including a revocation that arrives before
// the delivery - insert one; the unique externalId keeps concurrent inserts
// safe, and the loser retries the conditional update so the newest grant wins.
async function mirrorGrant(grant: Grant, status: 'active' | 'revoked') {
const grantUpdatedAt = new Date(grant.updated_at);
const fields = {
customerId: grant.customer_id,
status,
grantUpdatedAt,
...(grant.license_key && {
key: grant.license_key.key,
expiresAt: grant.license_key.expires_at ? new Date(grant.license_key.expires_at) : null,
activationsLimit: grant.license_key.activations_limit,
}),
};
const applyIfNewer = () =>
prisma.license.updateMany({
where: { externalId: grant.id, grantUpdatedAt: { lt: grantUpdatedAt } },
data: fields,
});
const updated = await applyIfNewer();
if (updated.count > 0) return;
// Either no row yet, or the stored copy is already as new or newer.
const inserted = await prisma.license.createMany({
data: [{ externalId: grant.id, ...fields }],
skipDuplicates: true,
});
if (inserted.count === 0) {
// A concurrent webhook inserted the row first. It may hold an OLDER grant,
// so re-run the conditional update; it is a no-op if the row is newer.
await applyIfNewer();
}
}
```
**Webhook events:**
- `entitlement_grant.created` — grant created; `status: "Delivered"` with a `license_key` for auto-fulfilled keys (the primary issuance event), `status: "Pending"` with no key for manual fulfillment
- `entitlement_grant.delivered` — an existing grant moved to `Delivered` (manual fulfillment, or a revoked grant restored); not sent for auto-fulfilled keys
- `entitlement_grant.revoked` — license key revoked (Dodo already disabled it; mirror the state, inspect `revocation_reason`)
- `license_key.created` — legacy event (still fires, but use `entitlement_grant.*` for new integrations)
Subscription `cancelled`/`expired`/`on_hold`/`paused` events need no license action from you: Dodo disables the keys itself, and `licenses.validate()` reflects it immediately.
---
## Common Mistakes
### 1. Validating only at install time
Don't validate once and trust forever. Validate periodically (e.g., weekly) to catch revoked or expired keys.
```typescript
// Bad: validate once
if (await validateLicense(key)) {
store.set('trusted', true);
}
// Good: validate on each startup
const isValid = await validateLicense(key);
if (!isValid) {
// Fail closed
process.exit(1);
}
```
### 2. Not handling activation-limit errors
When a user hits the activation limit, they need a clear path to deactivate an old device.
```typescript
try {
await client.licenses.activate({ license_key: key, name: deviceName });
} catch (error: any) {
if (error.status === 422) {
// Show UI: "You've reached your activation limit. Deactivate a device first."
// Provide a list of active instances so they can choose which to remove.
}
}
```
### 3. Trusting client-side validation alone
Always validate on your server before granting access to sensitive features.
```typescript
// Bad: trust the client
if (localStorage.getItem('license_valid')) {
showPremiumFeature();
}
// Good: validate server-side
const response = await fetch('/api/validate-license', {
method: 'POST',
body: JSON.stringify({ licenseKey }),
});
const { valid } = await response.json();
if (valid) {
showPremiumFeature();
}
```
### 4. Hardcoding license keys
Never embed keys in client code or version control.
```typescript
// Bad
const LICENSE_KEY = 'PRO-AAAA-BBBB-CCCC-DDDD';
// Good
const licenseKey = process.env.DODO_LICENSE_KEY;
// or read from user input / secure storage
```
### 5. Not storing the instance ID
The instance ID is required for deactivation. Store it alongside the key.
```typescript
// Bad: only store the key
store.set('license_key', key);
// Good: store both
store.set('license', {
key,
instanceId: response.id,
activatedAt: new Date().toISOString(),
lastValidatedAt: new Date().toISOString(),
});
```
### 6. Failing open on validation errors
If validation fails (network error, server down), fail closed. Don't grant access.
```typescript
// Bad: assume valid if offline
try {
const valid = await validateLicense(key);
return valid;
} catch {
return true; // WRONG: grants access on error
}
// Good: fail closed, with optional grace period
try {
const valid = await validateLicense(key);
return valid;
} catch {
// Only trust local cache if within grace period
const lastValidated = store.get('last_validated_at');
const daysSince = (Date.now() - lastValidated) / (1000 * 60 * 60 * 24);
return daysSince < 30;
}
```
---
## Resources
- [License Keys Guide](https://docs.dodopayments.com/features/license-keys)
- [Activate License API](https://docs.dodopayments.com/api-reference/licenses/activate-license)
- [Validate License API](https://docs.dodopayments.com/api-reference/licenses/validate-license)
- [Deactivate License API](https://docs.dodopayments.com/api-reference/licenses/deactivate-license)
- [License Key Instances API](https://docs.dodopayments.com/api-reference/licenses/get-license-key-instances)
- [List Customer Grants API](https://docs.dodopayments.com/api-reference/entitlements/list-customer-grants)
- [Entitlement Grant Webhooks](https://docs.dodopayments.com/developer-resources/webhooks/intents/entitlement-grant)
- [Webhook Verification](https://docs.dodopayments.com/developer-resources/webhooks/verification)
© dodopayments · MIT · dodopayments/skills