license-keys について
Dodo Payments License Keysは、ライセンスの有効化・検証と、販売者側のキー管理を実装するためのスキルです。デスクトップアプリ、コマンドラインツール、Webサービス向けに、デバイス制限、期限、サブスクリプション連動、キーのインポート、ライフサイクルWebhookを扱います。
主な機能
- 01キーの有効化・検証・無効化
- 02付与の管理とライセンスの取り消し
- 03デバイスごとのアクティベーション管理
- 04有効期限とアクティベーション制限への対応
- 05ライセンスライフサイクルWebhookの連携
活用シーン
- 01デスクトップアプリやCLIへのライセンス確認の追加
- 02販売者向けライセンス管理ダッシュボードの構築
- 03ライセンスやサブスクリプションのイベント処理
- 04別システムからのキーのインポート
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dodopayments/ skills --skill license-keysインストール後、Claude Code で/license-keysを入力して読み込まれたか確認します。
同梱ファイルreferences/cli-tool.mdreferences/desktop-app.md
詳細
Dodo Payments のライセンスキー
ライセンスキーは、デジタル製品へのアクセスを許可します。ソフトウェアライセンス、シート単位の制御、プレミアム機能の利用制限に使用できます。
このスキルを使用する場面
- エンドユーザー向けのライセンス認証および検証フローを実装する場合
- 顧客のライセンスキーを管理する販売者向けダッシュボードを構築する場合
- ライセンスの有効期限、認証上限、サブスクリプションに連動した取り消しを処理する場合
- デスクトップアプリ、CLI、ウェブサービスにライセンスチェックを統合する場合
- ライセンスのライフサイクルに関する Webhook に対応する場合
主な概念
ライセンスキーの権利付与では、製品の購入時にキーが提供されます。権利付与の設定には、認証上限、任意の有効期間、提供モード(自動または手動)が含まれます。
3 種類のリソース:
-
client.licenses.*— エンドユーザーの認証フロー(公開、API キー不要)activate()— デバイスでキーを認証しますvalidate()— キーが有効かどうかを確認しますdeactivate()— 認証枠を解放します
-
権利付与 — 販売者側での読み取りと取り消し(API キーが必要)
client.customers.listEntitlementGrants()、client.entitlements.grants.list()— 発行済みのキーを一覧表示します(各ライセンスキー権利付与にはlicense_keyオブジェクトが含まれます)client.entitlements.grants.revoke()— キーを取り消しますclient.licenseKeys.create()— 既存のキーをインポートする用途で引き続きサポートされていますclient.licenseKeys.list()/retrieve()/update()は非推奨です(GET /license_keys、GET/PATCH /license_keys/{id})。代わりに上記の権利付与エンドポイントを使用してください
-
client.licenseKeyInstances.*— デバイスごとの認証インスタンス(API キーが必要)list()、retrieve()、update()— キーごとのアクティブなデバイスを追跡します
認証上限: 空欄または null は無制限を意味します。それ以外の場合は、同時に認証できる最大数です。上限を超えて認証しようとすると、422 が返されます。
有効期限の仕様:
- 1 回限りの支払いのキーには、権利付与の有効期間が適用されます。
- サブスクリプションで発行されたキーには、独立した有効期限がありません。Dodo はサブスクリプションの状態に基づいて、有効性を自動的に制御します。
past_dueではキーは有効なままですが、on_holdとpausedではサブスクリプションが回復または再開するまで無効になります。cancelled/expiredでは永久に無効になり、plan_changedではキーが置き換えられます。1 回限りのrefund.succeededでもキーは無効になります。キーを自分で無効にする必要はありません。 - インポートしたキーでは
expires_atは null を許容し、null は無期限を意味します。
エンドユーザーの認証フロー
ライセンスキーを認証する
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' };
}
}
レスポンスには、id(インスタンス ID)、business_id、name、license_key_id、created_at、顧客の詳細、製品の詳細が含まれます。
ライセンスキーを検証する
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 };
}
}
license_key_instance_id を指定しない場合、検証ではキーが active で、有効期限が切れていないことのみを確認します。認証時に保存したインスタンス ID を渡してください。
ライセンスを無効化する
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 };
}
}
販売者側のキー管理
顧客のライセンスキーを一覧表示する
client.licenseKeys.list()、retrieve()、update() は非推奨です。権利付与エンドポイントを通じてキーを読み取ってください。各ライセンスキー権利付与には license_key オブジェクト(key、status、expires_at、activations_used、activations_limit)が含まれます。ただし、手動モードの権利付与がまだ Pending の場合は null です。
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;
}
1 つのライセンスキー権利付与に対して発行されたすべてのキーを一覧表示するには、client.entitlements.grants.list('ent_...', { status: 'Delivered' }) を使用してください。
ライセンスキーを取り消す
// 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' });
ライセンスキーをインポートする(POST /license_keys)
別のシステムからキーを移行する場合に使用します。インポートしたキーでは顧客へのメールは送信されません。顧客にはご自身で通知してください。Dodo が発行するキーについては、Dodo からメールが送信されます。
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
認証インスタンスを一覧表示する
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,
});
}
任意のクエリフィールド:page_size(デフォルト 10、最大 100)、page_number(デフォルト 0)、license_key_id、grant_id。
デスクトップアプリとの統合
詳しいガイド:references/desktop-app.md。
内容:
- Electron アプリの例
- ライセンス入力用の React コンポーネント
CLI ツールの統合
完全なガイド:references/cli-tool.md。
内容:
- Node.js CLI の例
- CLI コマンド
Webhook の統合
ライセンスキーの配信を処理する
Dodo は自動配信される各キーを生成し、購入者にメールで送信します(エンタイトルメントの有効化メッセージを含みます)。また、サブスクリプションの状態に応じてキーを無効化、再有効化、置き換えます。Webhook では、自社の記録に状態を反映するだけで十分です。キーを自分でメール送信したり、キャンセル時にキーを無効化したりしないでください。
ライセンスが有効な製品が購入されると、自動配信されたキーは entitlement_grant.created として届き、status: "Delivered" と license_key オブジェクトが含まれます。entitlement_grant.delivered は別途送信されません。entitlement_grant.delivered が発火するのは、付与が後から Delivered に移行した場合のみです(たとえば、Pending の付与を手動で履行した場合や、取り消された付与が復元された場合)。そのため、両方を処理してください。
// 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 イベント:
entitlement_grant.created— 付与が作成されます。自動配信キーの場合はlicense_keyを含むstatus: "Delivered"(主な発行イベント)、手動で履行する場合はキーを含まないstatus: "Pending"entitlement_grant.delivered— 既存の付与がDeliveredに移行します(手動での履行、または取り消された付与の復元)。自動配信キーの場合は送信されませんentitlement_grant.revoked— ライセンスキーが取り消されます(Dodo はすでに無効化しています。状態を反映し、revocation_reasonを確認してください)license_key.created— レガシーイベント(引き続き発火しますが、新しい統合ではentitlement_grant.*を使用してください)
サブスクリプションの cancelled/expired/on_hold/paused イベントについて、ライセンスに関する操作は不要です。Dodo がキーを無効化し、licenses.validate() にその状態がすぐに反映されます。
よくある間違い
1. インストール時にしか検証しない
一度検証しただけで、その後もずっと信頼しないでください。取り消し済みまたは期限切れのキーを検出するため、定期的に(たとえば毎週)検証してください。
// 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. アクティベーション上限のエラーを処理しない
ユーザーがアクティベーション上限に達した場合、古いデバイスを無効化するための明確な方法が必要です。
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. クライアント側の検証だけを信頼する
機密性の高い機能へのアクセスを許可する前に、必ずサーバーで検証してください。
// 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. ライセンスキーをハードコードする
キーをクライアントコードやバージョン管理に含めないでください。
// 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. インスタンス ID を保存しない
無効化にはインスタンス ID が必要です。キーと一緒に保存してください。
// 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. 検証エラー時にアクセスを許可する
検証に失敗した場合(ネットワークエラー、サーバーダウンなど)は、アクセスを拒否してください。アクセスを許可しないでください。
// 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;
}