mcpField
L
license-keys

license-keys

github.com/dodopayments开发13686 次安装3 周前更新
代码仓库

关于 license-keys

Dodo Payments License Keys 是用于实现许可证激活、验证及商户端密钥管理的技能。内容涵盖设备限制、到期和订阅状态处理、密钥导入及生命周期 Webhook,适用于桌面应用、命令行工具和 Web 服务。

主要功能

  1. 01激活、验证和停用密钥
  2. 02管理授权并撤销许可证
  3. 03跟踪各设备的激活实例
  4. 04处理到期时间和激活限制
  5. 05集成许可证生命周期 Webhook

适用场景

  1. 01为桌面应用或命令行工具添加许可证检查
  2. 02构建商户许可证管理面板
  3. 03处理许可证和订阅生命周期事件
  4. 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 许可证密钥

许可证密钥用于授权访问数字产品。可将其用于软件许可、按席位控制和高级功能门控。

适用场景

  • 实现终端用户许可证激活和验证流程
  • 构建用于管理客户许可证密钥的商家仪表板
  • 处理许可证到期、激活次数限制,以及与订阅关联的撤销
  • 将许可证检查集成到桌面应用、命令行工具或 Web 服务中
  • 响应许可证生命周期 Webhook

核心概念

许可证密钥权益会在产品售出时发放密钥。权益配置用于设置激活次数限制、可选时长和履约模式(自动或手动)。

三种不同的资源:

  1. client.licenses.* — 终端用户激活流程(公开,无需 API 密钥)

    • activate() — 在设备上激活密钥
    • validate() — 检查密钥是否有效
    • deactivate() — 释放一个激活名额
  2. 权益授权 — 商家端读取和撤销(需要 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});请改用上述授权端点
  3. client.licenseKeyInstances.* — 每台设备的激活实例(需要 API 密钥)

    • list()、retrieve()、update() — 跟踪每个密钥对应的已激活设备

**激活次数限制:**留空或设为 null 表示不限次数;否则表示允许的最大并发激活数。尝试超出限制时会返回 422。

到期规则:

  • 一次性付款密钥遵循权益时长。
  • 订阅发放的密钥没有独立的到期时间;Dodo 会根据订阅状态自动管理其有效性:past_due 时保持激活,on_hold 和 paused 时停用,直到订阅恢复或继续;cancelled/expired 时永久停用;plan_changed 时替换密钥。一次性付款发生 refund.succeeded 时也会停用密钥。你无需自行停用密钥。
  • 导入的密钥使用可空的 expires_at;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;
}

若要列出某个 License Key 权益发放的所有密钥,请使用 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。只有当某个授权稍后变为 Delivered 时,才会触发 entitlement_grant.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 — 创建授权;自动履行的密钥状态为 status: "Delivered",并包含 license_key(主要的密钥发放事件);手动履行的授权状态为 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;
}

资源

评论

支持 Markdown